From 13f588ae87c20635bd20437922f1b46dc7ec1503 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 16:26:33 -0600 Subject: [PATCH 01/25] Update vdom.py --- src/reactpy/core/vdom.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 7ecddcf0e..89f80204a 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -223,6 +223,8 @@ def separate_attributes_and_event_handlers( if callable(v): handler = EventHandler(to_event_handler_function(v)) + elif isinstance(v, str) and v.startswith("javascript:"): + handler = v elif isinstance(v, EventHandler): handler = v else: From edf0498e1d8629958aab5343d5b7c50ba686ab89 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 16:31:04 -0600 Subject: [PATCH 02/25] Update layout.py --- src/reactpy/core/layout.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index a32f97083..46f06f52a 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -277,10 +277,17 @@ def _render_model_attributes( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - if event in old_state.targets_by_event: - target = old_state.targets_by_event[event] + if isinstance(handler, str): + target = handler + prevent_default = False + stop_propagation = False else: - target = uuid4().hex if handler.target is None else handler.target + prevent_default = handler.prevent_default + stop_propagation = handler.stop_propagation + if event in old_state.targets_by_event: + target = old_state.targets_by_event[event] + else: + target = uuid4().hex if handler.target is None else handler.target new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { @@ -301,7 +308,14 @@ def _render_model_event_handlers_without_old_state( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - target = uuid4().hex if handler.target is None else handler.target + if isinstance(handler, str): + target = handler + prevent_default = False + stop_propagation = False + else: + target = uuid4().hex if handler.target is None else handler.target + prevent_default = handler.prevent_default + stop_propagation = handler.stop_propagation new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { From 8ae45ce9e20d39dea4ab12d20250c8f4f3cb9512 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 16:45:35 -0600 Subject: [PATCH 03/25] Update vdom.tsx --- src/js/packages/@reactpy/client/src/vdom.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index cae706787..7e5ea8005 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -198,6 +198,9 @@ function createEventHandler( name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { + if (target.indexOf("javascript:") == 0) { + return [name, eval(target.replace("javascript:", "")]; + } return [ name, function (...args: any[]) { From c11c2c40326c5b1e344b1a3398d528eab05542e5 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 16:47:33 -0600 Subject: [PATCH 04/25] Update react.js --- src/reactpy/web/templates/react.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/reactpy/web/templates/react.js b/src/reactpy/web/templates/react.js index 366be4fd0..8244d5a42 100644 --- a/src/reactpy/web/templates/react.js +++ b/src/reactpy/web/templates/react.js @@ -30,7 +30,11 @@ function wrapEventHandlers(props) { const newProps = Object.assign({}, props); for (const [key, value] of Object.entries(props)) { if (typeof value === "function") { - newProps[key] = makeJsonSafeEventHandler(value); + if (value.toString().includes(".sendMessage")) { + newProps[key] = makeJsonSafeEventHandler(value); + } else { + newProps[key] = value; + } } } return newProps; From db770ac2c9670c163446aa609861f8959f36ea70 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 17:53:06 -0600 Subject: [PATCH 05/25] Add missing parenthesis --- src/js/packages/@reactpy/client/src/vdom.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 7e5ea8005..44474a5b4 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -199,7 +199,7 @@ function createEventHandler( { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { if (target.indexOf("javascript:") == 0) { - return [name, eval(target.replace("javascript:", "")]; + return [name, eval(target.replace("javascript:", ""))]; } return [ name, From 67cd1e73a5d88208c8c676dac7c9eab0e9b84256 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 17:54:58 -0600 Subject: [PATCH 06/25] Update layout.py --- src/reactpy/core/layout.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 46f06f52a..f03526d73 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -292,8 +292,8 @@ def _render_model_attributes( self._event_handlers[target] = handler model_event_handlers[event] = { "target": target, - "preventDefault": handler.prevent_default, - "stopPropagation": handler.stop_propagation, + "preventDefault": prevent_default, + "stopPropagation": stop_propagation, } return None @@ -320,8 +320,8 @@ def _render_model_event_handlers_without_old_state( self._event_handlers[target] = handler model_event_handlers[event] = { "target": target, - "preventDefault": handler.prevent_default, - "stopPropagation": handler.stop_propagation, + "preventDefault": prevent_default, + "stopPropagation": stop_propagation, } return None From 36e33e41afb12ff4728152d75858906934e595cf Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 17:57:22 -0600 Subject: [PATCH 07/25] Update vdom.py --- src/reactpy/core/vdom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 89f80204a..fec8a6d83 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -216,7 +216,7 @@ def separate_attributes_and_event_handlers( attributes: Mapping[str, Any], ) -> tuple[VdomAttributes, EventHandlerDict]: _attributes: VdomAttributes = {} - _event_handlers: dict[str, EventHandlerType] = {} + _event_handlers: dict[str, EventHandlerType | str] = {} for k, v in attributes.items(): handler: EventHandlerType From 61fc1d397607e9c216e19cdc587e873df04be570 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 28 Mar 2025 18:14:09 -0600 Subject: [PATCH 08/25] Adds test --- tests/test_core/test_events.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 310ddc880..d3d995f7f 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -221,3 +221,36 @@ def outer_click_is_not_triggered(event): await inner.click() await poll(lambda: clicked.current).until_is(True) + + +async def test_javascript_event(display: DisplayFixture): + @reactpy.component + def App(): + return reactpy.html.div( + reactpy.html.div( + reactpy.html.button( + { + "id": "the-button", + "onClick": """javascript: () => { + let parent = document.getElementById("the-parent"); + parent.appendChild(document.createElement("div")); + }""", + }, + "Click Me", + ), + reactpy.html.div({"id": "the-parent"}), + ) + ) + + await display.show(lambda: App()) + + button = await display.page.wait_for_selector("#the-button", state="attached") + await button.click() + await button.click() + await button.click() + parent = await display.page.wait_for_selector( + "#the-parent", state="attached", timeout=0 + ) + generated_divs = await parent.query_selector_all("div") + + assert len(generated_divs) == 3 From 258c4de3bbe6f3ab99336ad4183aaf712a60bca1 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 28 Mar 2025 18:18:20 -0600 Subject: [PATCH 09/25] Update types.py --- src/reactpy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index ba8ce31f0..eeba05a1e 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -919,7 +919,7 @@ class EventHandlerType(Protocol): EventHandlerMapping = Mapping[str, EventHandlerType] """A generic mapping between event names to their handlers""" -EventHandlerDict: TypeAlias = dict[str, EventHandlerType] +EventHandlerDict: TypeAlias = dict[str, EventHandlerType | str] """A dict mapping between event names to their handlers""" From 5ce5c320ebbf0338b6a327890102f4d9478bc9d7 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 28 Mar 2025 18:26:02 -0600 Subject: [PATCH 10/25] Update types --- src/reactpy/core/layout.py | 2 +- src/reactpy/core/vdom.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index f03526d73..cd2586256 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -118,7 +118,7 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: # we just ignore the event. handler = self._event_handlers.get(event["target"]) - if handler is not None: + if handler is not None and not isinstance(handler, str): try: await handler.function(event["data"]) except Exception: diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index fec8a6d83..27ec80360 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -219,7 +219,7 @@ def separate_attributes_and_event_handlers( _event_handlers: dict[str, EventHandlerType | str] = {} for k, v in attributes.items(): - handler: EventHandlerType + handler: EventHandlerType | str if callable(v): handler = EventHandler(to_event_handler_function(v)) From 0b14bc85058fe9b0a82e783fdcb8827137019c30 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Fri, 28 Mar 2025 18:48:08 -0600 Subject: [PATCH 11/25] Add one more test --- tests/test_core/test_events.py | 52 ++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index d3d995f7f..860f5a600 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -232,9 +232,9 @@ def App(): { "id": "the-button", "onClick": """javascript: () => { - let parent = document.getElementById("the-parent"); - parent.appendChild(document.createElement("div")); - }""", + let parent = document.getElementById("the-parent"); + parent.appendChild(document.createElement("div")); + }""", }, "Click Me", ), @@ -254,3 +254,49 @@ def App(): generated_divs = await parent.query_selector_all("div") assert len(generated_divs) == 3 + + +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 From 23a729751ebd82690e44a2e4540cc82b0e07c2ae Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 31 Mar 2025 12:15:52 -0600 Subject: [PATCH 12/25] Implement JavaScript type and handle eval --- src/js/packages/@reactpy/client/src/vdom.tsx | 19 ++++- src/reactpy/core/layout.py | 11 +-- src/reactpy/core/vdom.py | 14 ++-- src/reactpy/types.py | 6 +- src/reactpy/web/templates/react.js | 8 +-- tests/test_core/test_events.py | 31 ++++++-- tests/test_web/js_fixtures/ag-grid-react.js | 76 ++++++++++++++++++++ tests/test_web/test_module.py | 50 +++++++++++++ 8 files changed, 191 insertions(+), 24 deletions(-) create mode 100644 tests/test_web/js_fixtures/ag-grid-react.js diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 44474a5b4..73db8f72e 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -198,8 +198,23 @@ function createEventHandler( name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { - if (target.indexOf("javascript:") == 0) { - return [name, eval(target.replace("javascript:", ""))]; + if (target.indexOf("__javascript__: ") == 0) { + return [ + name, + function (...args: any[]) { + function handleEvent(...args: any[]) { + const evalResult = eval(target.replace("__javascript__: ", "")); + if (typeof evalResult == "function") { + return evalResult(...args); + } + } + if (args.length > 0 && args[0] instanceof Event) { + return handleEvent.call(args[0].target, ...args); + } else { + return handleEvent(...args); + } + }, + ]; } return [ name, diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index cd2586256..db399fdc9 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -41,6 +41,7 @@ ComponentType, Context, EventHandlerDict, + JavaScript, Key, LayoutEventMessage, LayoutUpdateMessage, @@ -118,7 +119,7 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: # we just ignore the event. handler = self._event_handlers.get(event["target"]) - if handler is not None and not isinstance(handler, str): + if handler is not None and not isinstance(handler, JavaScript): try: await handler.function(event["data"]) except Exception: @@ -277,8 +278,8 @@ def _render_model_attributes( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - if isinstance(handler, str): - target = handler + if isinstance(handler, JavaScript): + target = "__javascript__: " + handler prevent_default = False stop_propagation = False else: @@ -308,8 +309,8 @@ def _render_model_event_handlers_without_old_state( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - if isinstance(handler, str): - target = handler + if isinstance(handler, JavaScript): + target = "__javascript__: " + handler prevent_default = False stop_propagation = False else: diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 27ec80360..3f6cf92d9 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -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,15 @@ EventHandlerDict, EventHandlerType, ImportSourceDict, + JavaScript, VdomAttributes, VdomChildren, VdomDict, VdomJson, ) +EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]") + VDOM_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema", "$ref": "#/definitions/element", @@ -216,16 +220,16 @@ def separate_attributes_and_event_handlers( attributes: Mapping[str, Any], ) -> tuple[VdomAttributes, EventHandlerDict]: _attributes: VdomAttributes = {} - _event_handlers: dict[str, EventHandlerType | str] = {} + _event_handlers: dict[str, EventHandlerType | JavaScript] = {} for k, v in attributes.items(): - handler: EventHandlerType | str + handler: EventHandlerType | JavaScript if callable(v): handler = EventHandler(to_event_handler_function(v)) - elif isinstance(v, str) and v.startswith("javascript:"): - handler = v - elif isinstance(v, EventHandler): + elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): + handler = JavaScript(v) + elif isinstance(v, (EventHandler, JavaScript)): handler = v else: _attributes[k] = v diff --git a/src/reactpy/types.py b/src/reactpy/types.py index eeba05a1e..0523b390a 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -885,6 +885,10 @@ class JsonImportSource(TypedDict): fallback: Any +class JavaScript(str): + pass + + class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" @@ -919,7 +923,7 @@ class EventHandlerType(Protocol): EventHandlerMapping = Mapping[str, EventHandlerType] """A generic mapping between event names to their handlers""" -EventHandlerDict: TypeAlias = dict[str, EventHandlerType | str] +EventHandlerDict: TypeAlias = dict[str, EventHandlerType | JavaScript] """A dict mapping between event names to their handlers""" diff --git a/src/reactpy/web/templates/react.js b/src/reactpy/web/templates/react.js index 8244d5a42..b6914c6bc 100644 --- a/src/reactpy/web/templates/react.js +++ b/src/reactpy/web/templates/react.js @@ -29,12 +29,8 @@ 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 (value.toString().includes(".sendMessage")) { - newProps[key] = makeJsonSafeEventHandler(value); - } else { - newProps[key] = value; - } + if (typeof value === "function" && value.toString().includes(".sendMessage")) { + newProps[key] = makeJsonSafeEventHandler(value); } } return newProps; diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 860f5a600..640d0a5fa 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -223,7 +223,7 @@ def outer_click_is_not_triggered(event): await poll(lambda: clicked.current).until_is(True) -async def test_javascript_event(display: DisplayFixture): +async def test_javascript_event_as_arrow_function(display: DisplayFixture): @reactpy.component def App(): return reactpy.html.div( @@ -231,10 +231,7 @@ def App(): reactpy.html.button( { "id": "the-button", - "onClick": """javascript: () => { - let parent = document.getElementById("the-parent"); - parent.appendChild(document.createElement("div")); - }""", + "onClick": '(e) => e.target.innerText = "Thank you!"', }, "Click Me", ), @@ -256,6 +253,30 @@ def App(): assert len(generated_divs) == 3 +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(): diff --git a/tests/test_web/js_fixtures/ag-grid-react.js b/tests/test_web/js_fixtures/ag-grid-react.js new file mode 100644 index 000000000..aeccae0f6 --- /dev/null +++ b/tests/test_web/js_fixtures/ag-grid-react.js @@ -0,0 +1,76 @@ +import React from "https://esm.sh/react@19.0" +import ReactDOM from "https://esm.sh/react-dom@19.0/client" +import {AgGridReact} from "https://esm.sh/ag-grid-react@32.2.0?deps=react@19.0,react-dom@19.0,react-is@19.0"; +export {AgGridReact}; + +loadCSS("https://unpkg.com/@ag-grid-community/styles@32.2.0/ag-grid.css"); +loadCSS("https://unpkg.com/@ag-grid-community/styles@32.2.0/ag-theme-quartz.css") + +function loadCSS(href) { + var head = document.getElementsByTagName('head')[0]; + + if (document.querySelectorAll(`link[href="${href}"]`).length === 0) { + // Creating link element + var style = document.createElement('link'); + style.id = href; + style.href = href; + style.type = 'text/css'; + style.rel = 'stylesheet'; + head.append(style); + } +} + +export function bind(node, config) { + const root = ReactDOM.createRoot(node); + return { + create: (component, props, children) => + React.createElement(component, wrapEventHandlers(props), ...children), + render: (element) => root.render(element), + unmount: () => root.unmount() + }; +} + +function wrapEventHandlers(props) { + const newProps = Object.assign({}, props); + for (const [key, value] of Object.entries(props)) { + if (typeof value === "function" && value.toString().includes('.sendMessage')) { + newProps[key] = makeJsonSafeEventHandler(value); + } + } + return newProps; +} + +function stringifyToDepth(val, depth, replacer, space) { + depth = isNaN(+depth) ? 1 : depth; + function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration) + 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?[]:{})); + } + return JSON.stringify(_build('', val, depth), null, space); +} + +function makeJsonSafeEventHandler(oldHandler) { + // Since we can't really know what the event handlers get passed we have to check if + // they are JSON serializable or not. We can allow normal synthetic events to pass + // through since the original handler already knows how to serialize those for us. + return function safeEventHandler() { + + var filteredArguments = []; + Array.from(arguments).forEach(function (arg) { + if (typeof arg === "object" && arg.nativeEvent) { + // this is probably a standard React synthetic event + filteredArguments.push(arg); + } else { + filteredArguments.push(JSON.parse(stringifyToDepth(arg, 3, (key, value) => { + if (key === '') return value; + try { + JSON.stringify(value); + return value; + } catch (err) { + return (typeof value === 'object') ? value : undefined; + } + }))) + } + }); + oldHandler(...Array.from(filteredArguments)); + }; +} \ No newline at end of file diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 9594be4ae..2bf6199b3 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -13,6 +13,7 @@ poll, ) from reactpy.web.module import NAME_SOURCE, WebModule +from reactpy.types import JavaScript JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" @@ -389,6 +390,55 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): assert len(form_label) == 1 +async def test_ag_grid_table(display: DisplayFixture): + module = reactpy.web.module_from_file( + "ag-grid-react", JS_FIXTURES_DIR / "ag-grid-react.js" + ) + AgGridReact = reactpy.web.export(module, "AgGridReact") + + @reactpy.component + def App(): + dummy_bool, set_dummy_bool = reactpy.hooks.use_state(False) + row_data, set_row_data = reactpy.hooks.use_state([ + { "make": "Tesla", "model": "Model Y", "price": 64950, "electric": True }, + { "make": "Ford", "model": "F-Series", "price": 33850, "electric": False }, + { "make": "Toyota", "model": "Corolla", "price": 29600, "electric": False }, + ]) + col_defs, set_col_defs = reactpy.hooks.use_state([ + { "field": "make" }, + { "field": "model" }, + { "field": "price" }, + { "field": "electric" }, + ]) + default_col_def = {"flex": 1} + row_selection = reactpy.hooks.use_memo(lambda: {"mode": "singleRow"}) + + return reactpy.html.div( + {"id": "the-parent", "style": {"height": "100vh", "width": "100vw"}, "class": "ag-theme-quartz"}, + AgGridReact({ + "style": {"height": "500px"}, + "rowData": row_data, + "columnDefs": col_defs, + "defaultColDef": default_col_def, + "selection": row_selection, + "onRowSelected": lambda x: set_dummy_bool(not dummy_bool), + "getRowId": JavaScript("(params) => String(params.data.model);") + }) + ) + + await display.show( + lambda: App() + ) + + table_body = await display.page.wait_for_selector(".ag-body-viewport", state="attached") + checkboxes = await table_body.query_selector_all(".ag-checkbox-input") + await checkboxes[0].click() + # Regrab checkboxes, since they should rerender + checkboxes = await table_body.query_selector_all(".ag-checkbox-input") + checked = await checkboxes[0].is_checked() + assert checked is True + + def test_module_from_string(): reactpy.web.module_from_string("temp", "old") with assert_reactpy_did_log(r"Existing web module .* will be replaced with"): From a109a3b20afa8706c7dd3eef2e1f2d8294de75fc Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 31 Mar 2025 12:22:52 -0600 Subject: [PATCH 13/25] Fix broken test --- tests/test_core/test_events.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 640d0a5fa..262570a74 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -242,15 +242,9 @@ def App(): 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() - await button.click() - await button.click() - parent = await display.page.wait_for_selector( - "#the-parent", state="attached", timeout=0 - ) - generated_divs = await parent.query_selector_all("div") - - assert len(generated_divs) == 3 + assert await button.inner_text() == "Thank you!" async def test_javascript_event_as_this_statement(display: DisplayFixture): From 3a159afac06d14ed054e0ac58353b2ea14f5336b Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 31 Mar 2025 13:20:10 -0600 Subject: [PATCH 14/25] Replaces test for callable non-event prop --- tests/test_web/js_fixtures/ag-grid-react.js | 76 --------------------- tests/test_web/js_fixtures/callable-prop.js | 26 +++++++ tests/test_web/test_module.py | 52 ++++---------- 3 files changed, 38 insertions(+), 116 deletions(-) delete mode 100644 tests/test_web/js_fixtures/ag-grid-react.js create mode 100644 tests/test_web/js_fixtures/callable-prop.js diff --git a/tests/test_web/js_fixtures/ag-grid-react.js b/tests/test_web/js_fixtures/ag-grid-react.js deleted file mode 100644 index aeccae0f6..000000000 --- a/tests/test_web/js_fixtures/ag-grid-react.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from "https://esm.sh/react@19.0" -import ReactDOM from "https://esm.sh/react-dom@19.0/client" -import {AgGridReact} from "https://esm.sh/ag-grid-react@32.2.0?deps=react@19.0,react-dom@19.0,react-is@19.0"; -export {AgGridReact}; - -loadCSS("https://unpkg.com/@ag-grid-community/styles@32.2.0/ag-grid.css"); -loadCSS("https://unpkg.com/@ag-grid-community/styles@32.2.0/ag-theme-quartz.css") - -function loadCSS(href) { - var head = document.getElementsByTagName('head')[0]; - - if (document.querySelectorAll(`link[href="${href}"]`).length === 0) { - // Creating link element - var style = document.createElement('link'); - style.id = href; - style.href = href; - style.type = 'text/css'; - style.rel = 'stylesheet'; - head.append(style); - } -} - -export function bind(node, config) { - const root = ReactDOM.createRoot(node); - return { - create: (component, props, children) => - React.createElement(component, wrapEventHandlers(props), ...children), - render: (element) => root.render(element), - unmount: () => root.unmount() - }; -} - -function wrapEventHandlers(props) { - const newProps = Object.assign({}, props); - for (const [key, value] of Object.entries(props)) { - if (typeof value === "function" && value.toString().includes('.sendMessage')) { - newProps[key] = makeJsonSafeEventHandler(value); - } - } - return newProps; -} - -function stringifyToDepth(val, depth, replacer, space) { - depth = isNaN(+depth) ? 1 : depth; - function _build(key, val, depth, o, a) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration) - 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?[]:{})); - } - return JSON.stringify(_build('', val, depth), null, space); -} - -function makeJsonSafeEventHandler(oldHandler) { - // Since we can't really know what the event handlers get passed we have to check if - // they are JSON serializable or not. We can allow normal synthetic events to pass - // through since the original handler already knows how to serialize those for us. - return function safeEventHandler() { - - var filteredArguments = []; - Array.from(arguments).forEach(function (arg) { - if (typeof arg === "object" && arg.nativeEvent) { - // this is probably a standard React synthetic event - filteredArguments.push(arg); - } else { - filteredArguments.push(JSON.parse(stringifyToDepth(arg, 3, (key, value) => { - if (key === '') return value; - try { - JSON.stringify(value); - return value; - } catch (err) { - return (typeof value === 'object') ? value : undefined; - } - }))) - } - }); - oldHandler(...Array.from(filteredArguments)); - }; -} \ No newline at end of file diff --git a/tests/test_web/js_fixtures/callable-prop.js b/tests/test_web/js_fixtures/callable-prop.js new file mode 100644 index 000000000..83ff1fc41 --- /dev/null +++ b/tests/test_web/js_fixtures/callable-prop.js @@ -0,0 +1,26 @@ +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; + +const html = htm.bind(h); + +export function bind(node, config) { + return { + create: (type, props, children) => h(type, props, ...children), + render: (element) => render(element, node), + unmount: () => render(null, node), + }; +} + +// The intention here is that Child components are passed in here so we check that the +// children of "the-parent" are "child-1" through "child-N" +export function Component(props) { + var text = "DEFAULT"; + if (props.setText && typeof props.setText === "function") { + text = props.setText("PREFIX TEXT: "); + } + return html` +
+ ${text} +
+ `; +} diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 2bf6199b3..5eb67f1e5 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -12,8 +12,8 @@ assert_reactpy_did_not_log, poll, ) -from reactpy.web.module import NAME_SOURCE, WebModule from reactpy.types import JavaScript +from reactpy.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" @@ -390,53 +390,25 @@ async def test_subcomponent_notation_as_obj_attrs(display: DisplayFixture): assert len(form_label) == 1 -async def test_ag_grid_table(display: DisplayFixture): +async def test_callable_prop_with_javacript(display: DisplayFixture): module = reactpy.web.module_from_file( - "ag-grid-react", JS_FIXTURES_DIR / "ag-grid-react.js" + "callable-prop", JS_FIXTURES_DIR / "callable-prop.js" ) - AgGridReact = reactpy.web.export(module, "AgGridReact") + Component = reactpy.web.export(module, "Component") @reactpy.component def App(): - dummy_bool, set_dummy_bool = reactpy.hooks.use_state(False) - row_data, set_row_data = reactpy.hooks.use_state([ - { "make": "Tesla", "model": "Model Y", "price": 64950, "electric": True }, - { "make": "Ford", "model": "F-Series", "price": 33850, "electric": False }, - { "make": "Toyota", "model": "Corolla", "price": 29600, "electric": False }, - ]) - col_defs, set_col_defs = reactpy.hooks.use_state([ - { "field": "make" }, - { "field": "model" }, - { "field": "price" }, - { "field": "electric" }, - ]) - default_col_def = {"flex": 1} - row_selection = reactpy.hooks.use_memo(lambda: {"mode": "singleRow"}) - - return reactpy.html.div( - {"id": "the-parent", "style": {"height": "100vh", "width": "100vw"}, "class": "ag-theme-quartz"}, - AgGridReact({ - "style": {"height": "500px"}, - "rowData": row_data, - "columnDefs": col_defs, - "defaultColDef": default_col_def, - "selection": row_selection, - "onRowSelected": lambda x: set_dummy_bool(not dummy_bool), - "getRowId": JavaScript("(params) => String(params.data.model);") - }) + return Component( + { + "id": "my-div", + "setText": JavaScript('(prefixText) => prefixText + "TEST 123"'), + } ) - await display.show( - lambda: App() - ) + await display.show(lambda: App()) - table_body = await display.page.wait_for_selector(".ag-body-viewport", state="attached") - checkboxes = await table_body.query_selector_all(".ag-checkbox-input") - await checkboxes[0].click() - # Regrab checkboxes, since they should rerender - checkboxes = await table_body.query_selector_all(".ag-checkbox-input") - checked = await checkboxes[0].is_checked() - assert checked is True + my_div = await display.page.wait_for_selector("#my-div", state="attached") + assert await my_div.inner_text() == "PREFIX TEXT: TEST 123" def test_module_from_string(): From 693b112efcf95e7eff31f24fd01f70963bee3d6e Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 1 Apr 2025 07:06:16 -0600 Subject: [PATCH 15/25] New branch off of #1289 to highlight vdom approach --- src/js/packages/@reactpy/client/src/types.ts | 1 + src/js/packages/@reactpy/client/src/vdom.tsx | 81 +++++++++++--------- src/reactpy/core/layout.py | 4 + src/reactpy/core/vdom.py | 37 +++++---- src/reactpy/types.py | 18 ++++- src/reactpy/web/templates/react.js | 2 +- 6 files changed, 90 insertions(+), 53 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 3c0330a07..3b34443e0 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -53,6 +53,7 @@ export type ReactPyVdom = { children?: (ReactPyVdom | string)[]; error?: string; eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; + jsExecutables?: { [key: string]: string }; importSource?: ReactPyVdomImportSource; }; diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 73db8f72e..a58c38b44 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -189,6 +189,11 @@ export function createAttributes( createEventHandler(client, name, handler), ), ), + ...Object.fromEntries( + Object.entries(model.jsExecutables || {}).map(([name, executable]) => + createJSExecutable(name, executable), + ), + ), }), ); } @@ -198,41 +203,43 @@ function createEventHandler( name: string, { target, preventDefault, stopPropagation }: ReactPyVdomEventHandler, ): [string, () => void] { - if (target.indexOf("__javascript__: ") == 0) { - return [ - name, - function (...args: any[]) { - function handleEvent(...args: any[]) { - const evalResult = eval(target.replace("__javascript__: ", "")); - if (typeof evalResult == "function") { - return evalResult(...args); - } - } - if (args.length > 0 && args[0] instanceof Event) { - return handleEvent.call(args[0].target, ...args); - } else { - return handleEvent(...args); - } - }, - ]; - } - 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; - 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; + 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]; } diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index db399fdc9..11f7a48cd 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -263,6 +263,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 diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 3f6cf92d9..c89b53091 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -25,6 +25,7 @@ EventHandlerType, ImportSourceDict, JavaScript, + JSExecutableDict, VdomAttributes, VdomChildren, VdomDict, @@ -46,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 @@ -75,6 +77,12 @@ }, "required": ["target"], }, + "elementJSExecutables": { + "type": "object", + "patternProperties": { + ".*": "str", + }, + }, "importSource": { "type": "object", "properties": { @@ -164,7 +172,9 @@ def __call__( """The entry point for the VDOM API, for example reactpy.html().""" 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) @@ -184,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 {}), } @@ -216,28 +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 | JavaScript] = {} + _event_handlers: dict[str, EventHandlerType] = {} + _js_executables: dict[str, JavaScript] = {} for k, v in attributes.items(): - handler: EventHandlerType | JavaScript - if callable(v): - handler = EventHandler(to_event_handler_function(v)) + _event_handlers[k] = EventHandler(to_event_handler_function(v)) + elif isinstance(v, EventHandler): + _event_handlers[k] = v elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): - handler = JavaScript(v) - elif isinstance(v, (EventHandler, JavaScript)): - handler = v + _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]: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 0523b390a..b727d65a2 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -768,6 +768,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", + "jsExecutables", "importSource", ] ALLOWED_VDOM_KEYS = { @@ -776,6 +777,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", + "jsExecutables", "importSource", } @@ -788,6 +790,7 @@ class VdomTypeDict(TypedDict): children: NotRequired[Sequence[ComponentType | VdomChild]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[EventHandlerDict] + jsExecutables: NotRequired[JavaScript] 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["jsExecutables"]) -> JSExecutableDict: ... + @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["jsExecutables"], value: JSExecutableDict + ) -> 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]] + jsExecutables: NotRequired[dict[str, JavaScript]] importSource: NotRequired[JsonImportSource] @@ -923,9 +933,15 @@ class EventHandlerType(Protocol): EventHandlerMapping = Mapping[str, EventHandlerType] """A generic mapping between event names to their handlers""" -EventHandlerDict: TypeAlias = dict[str, EventHandlerType | JavaScript] +EventHandlerDict: TypeAlias = dict[str, EventHandlerType] """A dict mapping between event names to their handlers""" +JSExecutableMapping = Mapping[str, JavaScript] +"""A generic mapping between event 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`""" diff --git a/src/reactpy/web/templates/react.js b/src/reactpy/web/templates/react.js index b6914c6bc..b4970d320 100644 --- a/src/reactpy/web/templates/react.js +++ b/src/reactpy/web/templates/react.js @@ -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" && value.toString().includes(".sendMessage")) { + if (typeof value === "function" && value.isHandler) { newProps[key] = makeJsonSafeEventHandler(value); } } From c3ddb45f90a940eb47081191c8647186b8dd3342 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 1 Apr 2025 07:17:18 -0600 Subject: [PATCH 16/25] Remove now-needless JavaScript distinction logic --- src/reactpy/core/layout.py | 33 +++++++++------------------------ 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 11f7a48cd..9342e590c 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -41,7 +41,6 @@ ComponentType, Context, EventHandlerDict, - JavaScript, Key, LayoutEventMessage, LayoutUpdateMessage, @@ -119,7 +118,7 @@ async def deliver(self, event: LayoutEventMessage | dict[str, Any]) -> None: # we just ignore the event. handler = self._event_handlers.get(event["target"]) - if handler is not None and not isinstance(handler, JavaScript): + if handler is not None: try: await handler.function(event["data"]) except Exception: @@ -282,23 +281,16 @@ def _render_model_attributes( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - if isinstance(handler, JavaScript): - target = "__javascript__: " + handler - prevent_default = False - stop_propagation = False + if event in old_state.targets_by_event: + target = old_state.targets_by_event[event] else: - prevent_default = handler.prevent_default - stop_propagation = handler.stop_propagation - if event in old_state.targets_by_event: - target = old_state.targets_by_event[event] - else: - target = uuid4().hex if handler.target is None else handler.target + target = uuid4().hex if handler.target is None else handler.target new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { "target": target, - "preventDefault": prevent_default, - "stopPropagation": stop_propagation, + "preventDefault": handler.prevent_default, + "stopPropagation": handler.stop_propagation, } return None @@ -313,20 +305,13 @@ def _render_model_event_handlers_without_old_state( model_event_handlers = new_state.model.current["eventHandlers"] = {} for event, handler in handlers_by_event.items(): - if isinstance(handler, JavaScript): - target = "__javascript__: " + handler - prevent_default = False - stop_propagation = False - else: - target = uuid4().hex if handler.target is None else handler.target - prevent_default = handler.prevent_default - stop_propagation = handler.stop_propagation + target = uuid4().hex if handler.target is None else handler.target new_state.targets_by_event[event] = target self._event_handlers[target] = handler model_event_handlers[event] = { "target": target, - "preventDefault": prevent_default, - "stopPropagation": stop_propagation, + "preventDefault": handler.prevent_default, + "stopPropagation": handler.stop_propagation, } return None From 4920d95422b2aac74da5a3b37193fa1874b884b2 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 1 Apr 2025 07:31:40 -0600 Subject: [PATCH 17/25] Remove irrelevant comment --- tests/test_web/js_fixtures/callable-prop.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_web/js_fixtures/callable-prop.js b/tests/test_web/js_fixtures/callable-prop.js index 83ff1fc41..d16dd333a 100644 --- a/tests/test_web/js_fixtures/callable-prop.js +++ b/tests/test_web/js_fixtures/callable-prop.js @@ -11,8 +11,6 @@ export function bind(node, config) { }; } -// The intention here is that Child components are passed in here so we check that the -// children of "the-parent" are "child-1" through "child-N" export function Component(props) { var text = "DEFAULT"; if (props.setText && typeof props.setText === "function") { From 5d7dbdd8b70cb4038240cac2763409b94c533507 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 1 Apr 2025 10:12:56 -0600 Subject: [PATCH 18/25] Adds test for string_to_reactpy --- src/reactpy/core/vdom.py | 2 +- src/reactpy/types.py | 5 +++-- tests/test_utils.py | 9 +++++++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index c89b53091..48396ae81 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -32,7 +32,7 @@ VdomJson, ) -EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]") +EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on\w+") VDOM_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema", diff --git a/src/reactpy/types.py b/src/reactpy/types.py index b727d65a2..99c865a3e 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -790,7 +790,7 @@ class VdomTypeDict(TypedDict): children: NotRequired[Sequence[ComponentType | VdomChild]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[EventHandlerDict] - jsExecutables: NotRequired[JavaScript] + jsExecutables: NotRequired[JSExecutableDict] importSource: NotRequired[ImportSourceDict] @@ -896,6 +896,7 @@ class JsonImportSource(TypedDict): class JavaScript(str): + """A simple way of marking JavaScript code to be executed client-side""" pass @@ -937,7 +938,7 @@ class EventHandlerType(Protocol): """A dict mapping between event names to their handlers""" JSExecutableMapping = Mapping[str, JavaScript] -"""A generic mapping between event names to their javascript""" +"""A generic mapping between attribute names to their javascript""" JSExecutableDict: TypeAlias = dict[str, JavaScript] """A dict mapping between attribute names to their javascript""" diff --git a/tests/test_utils.py b/tests/test_utils.py index aa2905c05..12950c57a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -194,6 +194,15 @@ def test_string_to_reactpy(case): "key": "my-key", }, }, + # 9: Includes `jsExecutables` attribue + { + "source": '''''', + "model": { + "tagName": "button", + "jsExecutables": {"onclick": "this.innerText = 'CLICKED'"}, + "children": ["Click Me"], + } + }, ], ) def test_string_to_reactpy_default_transforms(case): From 435fcbc3adff56d240fa591ce765dc064de17dc1 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Tue, 1 Apr 2025 10:14:13 -0600 Subject: [PATCH 19/25] Apply hatch fmt --- src/reactpy/types.py | 1 + tests/test_utils.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 99c865a3e..95fec4ce2 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -897,6 +897,7 @@ class JsonImportSource(TypedDict): class JavaScript(str): """A simple way of marking JavaScript code to be executed client-side""" + pass diff --git a/tests/test_utils.py b/tests/test_utils.py index 12950c57a..d33896194 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -196,12 +196,12 @@ def test_string_to_reactpy(case): }, # 9: Includes `jsExecutables` attribue { - "source": '''''', + "source": """""", "model": { "tagName": "button", "jsExecutables": {"onclick": "this.innerText = 'CLICKED'"}, "children": ["Click Me"], - } + }, }, ], ) From d809007557db4e2d3f6b95419931182abee0239f Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Wed, 2 Apr 2025 22:31:48 -0600 Subject: [PATCH 20/25] Update src/reactpy/types.py Co-authored-by: Mark Bakhit --- src/reactpy/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 95fec4ce2..184d5b904 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -896,7 +896,7 @@ class JsonImportSource(TypedDict): class JavaScript(str): - """A simple way of marking JavaScript code to be executed client-side""" + """Simple subclass that flags a user's string in ReactPy VDOM attributes as executable JavaScript.""" pass From 5790e11a504eee92ac5736d9fdf70b05479f51f2 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 3 Apr 2025 22:27:21 -0600 Subject: [PATCH 21/25] Rename "jsExecutables" to "inlineJavascript" --- docs/source/about/changelog.rst | 1 + src/js/packages/@reactpy/client/src/types.ts | 2 +- src/js/packages/@reactpy/client/src/vdom.tsx | 10 ++++---- src/reactpy/core/layout.py | 6 ++--- src/reactpy/core/vdom.py | 26 ++++++++++---------- src/reactpy/transforms.py | 23 ++++++++++------- src/reactpy/types.py | 20 +++++++-------- src/reactpy/utils.py | 3 ++- tests/test_utils.py | 4 +-- 9 files changed, 51 insertions(+), 44 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 261d948c0..ac50b6d82 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -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** diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 3b34443e0..443dbd799 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -53,7 +53,7 @@ export type ReactPyVdom = { children?: (ReactPyVdom | string)[]; error?: string; eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; - jsExecutables?: { [key: string]: string }; + inlineJavascript?: { [key: string]: string }; importSource?: ReactPyVdomImportSource; }; diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index a58c38b44..9f82f7ac8 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -190,8 +190,8 @@ export function createAttributes( ), ), ...Object.fromEntries( - Object.entries(model.jsExecutables || {}).map(([name, executable]) => - createJSExecutable(name, executable), + Object.entries(model.inlineJavascript || {}).map(([name, inlineJavaScript]) => + createInlineJavascript(name, inlineJavaScript), ), ), }), @@ -223,13 +223,13 @@ function createEventHandler( return [name, eventHandler]; } -function createJSExecutable( +function createInlineJavascript( name: string, - executable: string, + inlineJavaScript: string, ): [string, () => void] { const wrappedExecutable = function (...args: any[]) { function handleExecution(...args: any[]) { - const evalResult = eval(executable); + const evalResult = eval(inlineJavaScript); if (typeof evalResult == "function") { return evalResult(...args); } diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index 9342e590c..fddc22dbd 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -262,9 +262,9 @@ 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 "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( diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index 48396ae81..d1a248a53 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -25,14 +25,14 @@ EventHandlerType, ImportSourceDict, JavaScript, - JSExecutableDict, + InlineJavascriptDict, VdomAttributes, VdomChildren, VdomDict, VdomJson, ) -EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on\w+") +EVENT_ATTRIBUTE_PATTERN = re.compile(r"^on[A-Z]\w+") VDOM_JSON_SCHEMA = { "$schema": "http://json-schema.org/draft-07/schema", @@ -47,7 +47,7 @@ "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, - "jsExecutables": {"$ref": "#/definitions/elementJSExecutables"}, + "inlineJavascript": {"$ref": "#/definitions/elementInlineJavascripts"}, "importSource": {"$ref": "#/definitions/importSource"}, }, # The 'tagName' is required because its presence is a useful indicator of @@ -77,7 +77,7 @@ }, "required": ["target"], }, - "elementJSExecutables": { + "elementInlineJavascripts": { "type": "object", "patternProperties": { ".*": "str", @@ -172,8 +172,8 @@ def __call__( """The entry point for the VDOM API, for example reactpy.html().""" attributes, children = separate_attributes_and_children(attributes_and_children) key = attributes.get("key", None) - attributes, event_handlers, js_executables = ( - separate_attributes_handlers_and_executables(attributes) + attributes, event_handlers, inline_javascript = ( + separate_attributes_handlers_and_inline_javascript(attributes) ) if REACTPY_CHECK_JSON_ATTRS.current: json.dumps(attributes) @@ -194,7 +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 {}), + **({"inlineJavascript": inline_javascript} if inline_javascript else {}), **({"importSource": self.import_source} if self.import_source else {}), } @@ -227,12 +227,12 @@ def separate_attributes_and_children( return _attributes, _children -def separate_attributes_handlers_and_executables( +def separate_attributes_handlers_and_inline_javascript( attributes: Mapping[str, Any], -) -> tuple[VdomAttributes, EventHandlerDict, JSExecutableDict]: +) -> tuple[VdomAttributes, EventHandlerDict, InlineJavascriptDict]: _attributes: VdomAttributes = {} _event_handlers: dict[str, EventHandlerType] = {} - _js_executables: dict[str, JavaScript] = {} + _inline_javascript: dict[str, JavaScript] = {} for k, v in attributes.items(): if callable(v): @@ -240,13 +240,13 @@ def separate_attributes_handlers_and_executables( elif isinstance(v, EventHandler): _event_handlers[k] = v elif EVENT_ATTRIBUTE_PATTERN.match(k) and isinstance(v, str): - _js_executables[k] = JavaScript(v) + _inline_javascript[k] = JavaScript(v) elif isinstance(v, JavaScript): - _js_executables[k] = v + _inline_javascript[k] = v else: _attributes[k] = v - return _attributes, _event_handlers, _js_executables + return _attributes, _event_handlers, _inline_javascript def _flatten_children(children: Sequence[Any]) -> list[Any]: diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index cdac48c7e..7d0529ef9 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -35,16 +35,21 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: if ":" in part ) } - + @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 @staticmethod def textarea_children_to_prop(vdom: VdomDict) -> None: diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 184d5b904..377cf6a44 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -768,7 +768,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", - "jsExecutables", + "inlineJavascript", "importSource", ] ALLOWED_VDOM_KEYS = { @@ -777,7 +777,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", - "jsExecutables", + "inlineJavascript", "importSource", } @@ -790,7 +790,7 @@ class VdomTypeDict(TypedDict): children: NotRequired[Sequence[ComponentType | VdomChild]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[EventHandlerDict] - jsExecutables: NotRequired[JSExecutableDict] + inlineJavascript: NotRequired[InlineJavascriptDict] importSource: NotRequired[ImportSourceDict] @@ -821,7 +821,7 @@ def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ... @overload def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ... @overload - def __getitem__(self, key: Literal["jsExecutables"]) -> JSExecutableDict: ... + def __getitem__(self, key: Literal["inlineJavascript"]) -> InlineJavascriptDict: ... @overload def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ... def __getitem__(self, key: VdomDictKeys) -> Any: @@ -845,7 +845,7 @@ def __setitem__( ) -> None: ... @overload def __setitem__( - self, key: Literal["jsExecutables"], value: JSExecutableDict + self, key: Literal["inlineJavascript"], value: InlineJavascriptDict ) -> None: ... @overload def __setitem__( @@ -880,7 +880,7 @@ class VdomJson(TypedDict): children: NotRequired[list[Any]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[dict[str, JsonEventTarget]] - jsExecutables: NotRequired[dict[str, JavaScript]] + inlineJavascript: NotRequired[dict[str, JavaScript]] importSource: NotRequired[JsonImportSource] @@ -938,11 +938,11 @@ 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""" +InlineJavascriptMapping = Mapping[str, JavaScript] +"""A generic mapping between attribute names to their inline javascript""" -JSExecutableDict: TypeAlias = dict[str, JavaScript] -"""A dict mapping between attribute names to their javascript""" +InlineJavascriptDict: TypeAlias = dict[str, JavaScript] +"""A dict mapping between attribute names to their inline javascript""" class VdomConstructor(Protocol): diff --git a/src/reactpy/utils.py b/src/reactpy/utils.py index 2bbe675ac..78629bb3b 100644 --- a/src/reactpy/utils.py +++ b/src/reactpy/utils.py @@ -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) diff --git a/tests/test_utils.py b/tests/test_utils.py index d33896194..0beaaf50f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -194,12 +194,12 @@ def test_string_to_reactpy(case): "key": "my-key", }, }, - # 9: Includes `jsExecutables` attribue + # 9: Includes `inlineJavascript` attribue { "source": """""", "model": { "tagName": "button", - "jsExecutables": {"onclick": "this.innerText = 'CLICKED'"}, + "inlineJavascript": {"onClick": "this.innerText = 'CLICKED'"}, "children": ["Click Me"], }, }, From 7c7f8516d5b5d94fc18f52606907dae65b814620 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Thu, 3 Apr 2025 22:33:43 -0600 Subject: [PATCH 22/25] Apply formatting --- src/js/packages/@reactpy/client/src/vdom.tsx | 5 +++-- src/reactpy/core/vdom.py | 6 ++++-- src/reactpy/transforms.py | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index 9f82f7ac8..d376c2312 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -190,8 +190,9 @@ export function createAttributes( ), ), ...Object.fromEntries( - Object.entries(model.inlineJavascript || {}).map(([name, inlineJavaScript]) => - createInlineJavascript(name, inlineJavaScript), + Object.entries(model.inlineJavascript || {}).map( + ([name, inlineJavaScript]) => + createInlineJavascript(name, inlineJavaScript), ), ), }), diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index d1a248a53..ee9b01a25 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -24,8 +24,8 @@ EventHandlerDict, EventHandlerType, ImportSourceDict, - JavaScript, InlineJavascriptDict, + JavaScript, VdomAttributes, VdomChildren, VdomDict, @@ -194,7 +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 {}), + **( + {"inlineJavascript": inline_javascript} if inline_javascript else {} + ), **({"importSource": self.import_source} if self.import_source else {}), } diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index 7d0529ef9..c5709790f 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -35,12 +35,12 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: if ":" in part ) } - + @staticmethod 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 + + 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). """ From 59bb9f5b9ab4dd93c76c7fcbc9c4a592bcc234db Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Mon, 14 Apr 2025 06:14:59 -0600 Subject: [PATCH 23/25] Ensure consistent capitalization for JavaScript --- src/js/packages/@reactpy/client/src/types.ts | 2 +- src/js/packages/@reactpy/client/src/vdom.tsx | 14 +++++++++++--- src/reactpy/core/layout.py | 6 +++--- src/reactpy/core/vdom.py | 18 +++++++++--------- src/reactpy/types.py | 18 +++++++++--------- tests/test_utils.py | 4 ++-- tests/test_web/test_module.py | 4 ++-- 7 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/js/packages/@reactpy/client/src/types.ts b/src/js/packages/@reactpy/client/src/types.ts index 443dbd799..148a3486c 100644 --- a/src/js/packages/@reactpy/client/src/types.ts +++ b/src/js/packages/@reactpy/client/src/types.ts @@ -53,7 +53,7 @@ export type ReactPyVdom = { children?: (ReactPyVdom | string)[]; error?: string; eventHandlers?: { [key: string]: ReactPyVdomEventHandler }; - inlineJavascript?: { [key: string]: string }; + inlineJavaScript?: { [key: string]: string }; importSource?: ReactPyVdomImportSource; }; diff --git a/src/js/packages/@reactpy/client/src/vdom.tsx b/src/js/packages/@reactpy/client/src/vdom.tsx index d376c2312..4bd882ff4 100644 --- a/src/js/packages/@reactpy/client/src/vdom.tsx +++ b/src/js/packages/@reactpy/client/src/vdom.tsx @@ -190,9 +190,9 @@ export function createAttributes( ), ), ...Object.fromEntries( - Object.entries(model.inlineJavascript || {}).map( + Object.entries(model.inlineJavaScript || {}).map( ([name, inlineJavaScript]) => - createInlineJavascript(name, inlineJavaScript), + createInlineJavaScript(name, inlineJavaScript), ), ), }), @@ -224,10 +224,12 @@ function createEventHandler( return [name, eventHandler]; } -function createInlineJavascript( +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); @@ -236,8 +238,14 @@ function createInlineJavascript( } } 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); } }; diff --git a/src/reactpy/core/layout.py b/src/reactpy/core/layout.py index fddc22dbd..a81ecc6d7 100644 --- a/src/reactpy/core/layout.py +++ b/src/reactpy/core/layout.py @@ -262,9 +262,9 @@ 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 "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( diff --git a/src/reactpy/core/vdom.py b/src/reactpy/core/vdom.py index ee9b01a25..8d70af53d 100644 --- a/src/reactpy/core/vdom.py +++ b/src/reactpy/core/vdom.py @@ -24,8 +24,8 @@ EventHandlerDict, EventHandlerType, ImportSourceDict, - InlineJavascriptDict, - JavaScript, + InlineJavaScript, + InlineJavaScriptDict, VdomAttributes, VdomChildren, VdomDict, @@ -47,7 +47,7 @@ "children": {"$ref": "#/definitions/elementChildren"}, "attributes": {"type": "object"}, "eventHandlers": {"$ref": "#/definitions/elementEventHandlers"}, - "inlineJavascript": {"$ref": "#/definitions/elementInlineJavascripts"}, + "inlineJavaScript": {"$ref": "#/definitions/elementInlineJavaScripts"}, "importSource": {"$ref": "#/definitions/importSource"}, }, # The 'tagName' is required because its presence is a useful indicator of @@ -77,7 +77,7 @@ }, "required": ["target"], }, - "elementInlineJavascripts": { + "elementInlineJavaScripts": { "type": "object", "patternProperties": { ".*": "str", @@ -195,7 +195,7 @@ def __call__( **({"attributes": attributes} if attributes else {}), **({"eventHandlers": event_handlers} if event_handlers else {}), **( - {"inlineJavascript": inline_javascript} if inline_javascript else {} + {"inlineJavaScript": inline_javascript} if inline_javascript else {} ), **({"importSource": self.import_source} if self.import_source else {}), } @@ -231,10 +231,10 @@ def separate_attributes_and_children( def separate_attributes_handlers_and_inline_javascript( attributes: Mapping[str, Any], -) -> tuple[VdomAttributes, EventHandlerDict, InlineJavascriptDict]: +) -> tuple[VdomAttributes, EventHandlerDict, InlineJavaScriptDict]: _attributes: VdomAttributes = {} _event_handlers: dict[str, EventHandlerType] = {} - _inline_javascript: dict[str, JavaScript] = {} + _inline_javascript: dict[str, InlineJavaScript] = {} for k, v in attributes.items(): if callable(v): @@ -242,8 +242,8 @@ def separate_attributes_handlers_and_inline_javascript( elif isinstance(v, EventHandler): _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] = InlineJavaScript(v) + elif isinstance(v, InlineJavaScript): _inline_javascript[k] = v else: _attributes[k] = v diff --git a/src/reactpy/types.py b/src/reactpy/types.py index 377cf6a44..2f0fbed8e 100644 --- a/src/reactpy/types.py +++ b/src/reactpy/types.py @@ -768,7 +768,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", - "inlineJavascript", + "inlineJavaScript", "importSource", ] ALLOWED_VDOM_KEYS = { @@ -777,7 +777,7 @@ class DangerouslySetInnerHTML(TypedDict): "children", "attributes", "eventHandlers", - "inlineJavascript", + "inlineJavaScript", "importSource", } @@ -790,7 +790,7 @@ class VdomTypeDict(TypedDict): children: NotRequired[Sequence[ComponentType | VdomChild]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[EventHandlerDict] - inlineJavascript: NotRequired[InlineJavascriptDict] + inlineJavaScript: NotRequired[InlineJavaScriptDict] importSource: NotRequired[ImportSourceDict] @@ -821,7 +821,7 @@ def __getitem__(self, key: Literal["attributes"]) -> VdomAttributes: ... @overload def __getitem__(self, key: Literal["eventHandlers"]) -> EventHandlerDict: ... @overload - def __getitem__(self, key: Literal["inlineJavascript"]) -> InlineJavascriptDict: ... + def __getitem__(self, key: Literal["inlineJavaScript"]) -> InlineJavaScriptDict: ... @overload def __getitem__(self, key: Literal["importSource"]) -> ImportSourceDict: ... def __getitem__(self, key: VdomDictKeys) -> Any: @@ -845,7 +845,7 @@ def __setitem__( ) -> None: ... @overload def __setitem__( - self, key: Literal["inlineJavascript"], value: InlineJavascriptDict + self, key: Literal["inlineJavaScript"], value: InlineJavaScriptDict ) -> None: ... @overload def __setitem__( @@ -880,7 +880,7 @@ class VdomJson(TypedDict): children: NotRequired[list[Any]] attributes: NotRequired[VdomAttributes] eventHandlers: NotRequired[dict[str, JsonEventTarget]] - inlineJavascript: NotRequired[dict[str, JavaScript]] + inlineJavaScript: NotRequired[dict[str, InlineJavaScript]] importSource: NotRequired[JsonImportSource] @@ -895,7 +895,7 @@ class JsonImportSource(TypedDict): fallback: Any -class JavaScript(str): +class InlineJavaScript(str): """Simple subclass that flags a user's string in ReactPy VDOM attributes as executable JavaScript.""" pass @@ -938,10 +938,10 @@ class EventHandlerType(Protocol): EventHandlerDict: TypeAlias = dict[str, EventHandlerType] """A dict mapping between event names to their handlers""" -InlineJavascriptMapping = Mapping[str, JavaScript] +InlineJavaScriptMapping = Mapping[str, InlineJavaScript] """A generic mapping between attribute names to their inline javascript""" -InlineJavascriptDict: TypeAlias = dict[str, JavaScript] +InlineJavaScriptDict: TypeAlias = dict[str, InlineJavaScript] """A dict mapping between attribute names to their inline javascript""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 0beaaf50f..e9d2f32f9 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -194,12 +194,12 @@ def test_string_to_reactpy(case): "key": "my-key", }, }, - # 9: Includes `inlineJavascript` attribue + # 9: Includes `inlineJavaScript` attribue { "source": """""", "model": { "tagName": "button", - "inlineJavascript": {"onClick": "this.innerText = 'CLICKED'"}, + "inlineJavaScript": {"onClick": "this.innerText = 'CLICKED'"}, "children": ["Click Me"], }, }, diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 5eb67f1e5..d233396fc 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -12,7 +12,7 @@ assert_reactpy_did_not_log, poll, ) -from reactpy.types import JavaScript +from reactpy.types import InlineJavaScript from reactpy.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" @@ -401,7 +401,7 @@ def App(): return Component( { "id": "my-div", - "setText": JavaScript('(prefixText) => prefixText + "TEST 123"'), + "setText": InlineJavaScript('(prefixText) => prefixText + "TEST 123"'), } ) From 113611e4952275fa67799a3ba99f2da87bf25703 Mon Sep 17 00:00:00 2001 From: Shawn Crawley Date: Sat, 19 Apr 2025 17:08:21 -0600 Subject: [PATCH 24/25] Convert private staticmethod to standalone method --- src/reactpy/transforms.py | 25 ++++++++++--------------- src/reactpy/utils.py | 7 +++++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/reactpy/transforms.py b/src/reactpy/transforms.py index c5709790f..072653c95 100644 --- a/src/reactpy/transforms.py +++ b/src/reactpy/transforms.py @@ -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,21 +46,6 @@ def normalize_style_attributes(self, vdom: dict[str, Any]) -> None: ) } - @staticmethod - 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 - @staticmethod def textarea_children_to_prop(vdom: VdomDict) -> None: """Transformation that converts the text content of a