From 13f588ae87c20635bd20437922f1b46dc7ec1503 Mon Sep 17 00:00:00 2001 From: ShawnCrawley-NOAA Date: Fri, 28 Mar 2025 16:26:33 -0600 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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():