From 26125ef34f5a81331a8839e4c7b5f6fa394a0f5c Mon Sep 17 00:00:00 2001 From: "Maarten A. Breddels" Date: Wed, 27 Dec 2023 14:28:42 +0100 Subject: [PATCH] refactor!: make widget composable by allowing children BREAKING CHANGE: this is a breaking change because of renaming set_value to setValue on the frontend side. This commit makes several changes, instead of having a single ReactWidget we now have a Widget (with no default value trait) and a ValueWidget (with a default value trait). Furthermore, by specificing _module and _type instead of _esm we can now render any React component from any ES module, or even standard html components like
or . The main (wrapper) component is now created in the model, which makes it easier to obtain the components of children. Once the main wrapper component is created, the while children tree is also resolved, and a synchroneous render can be made in one go. --- README.md | 10 +- ipyreact/__init__.py | 2 +- ipyreact/widget.py | 74 ++++-- src/utils.ts | 32 +++ src/widget.tsx | 553 ++++++++++++++++++++++++++++----------- tests/ui/event_test.py | 2 +- tests/ui/jupyter_test.py | 4 +- tests/ui/library_test.py | 4 +- 8 files changed, 497 insertions(+), 184 deletions(-) diff --git a/README.md b/README.md index d775e01..37ef061 100644 --- a/README.md +++ b/README.md @@ -25,13 +25,13 @@ Take any [Material UI example](https://mui.com/material-ui/react-rating/), copy/ import ipyreact -class ConfettiWidget(ipyreact.ReactWidget): +class ConfettiWidget(ipyreact.ValueWidget): _esm = """ import confetti from "canvas-confetti"; import * as React from "react"; - export default function({value, set_value, debug}) { - return };""" @@ -90,8 +90,8 @@ Then use the `%%react` magic to directly write jsx/tsx in your notebook: import confetti from "canvas-confetti"; import * as React from "react"; -export default function({value, set_value, debug}) { - return }; diff --git a/ipyreact/__init__.py b/ipyreact/__init__.py index e18be06..3381516 100644 --- a/ipyreact/__init__.py +++ b/ipyreact/__init__.py @@ -5,7 +5,7 @@ # Distributed under the terms of the Modified BSD License. from ._version import __version__ -from .widget import ReactWidget +from .widget import ReactWidget, ValueWidget, Widget def _jupyter_labextension_paths(): diff --git a/ipyreact/widget.py b/ipyreact/widget.py index 1359f6c..69a19de 100644 --- a/ipyreact/widget.py +++ b/ipyreact/widget.py @@ -8,17 +8,21 @@ TODO: Add module docstring """ +import typing as t +import warnings from pathlib import Path import anywidget -from traitlets import Any, Bool, Dict, Int, List, Unicode +from ipywidgets import ValueWidget as ValueWidgetClassic +from ipywidgets import Widget, widget_serialization +from traitlets import Any, Bool, Dict, Int, List, Unicode, observe from ._frontend import module_name, module_version HERE = Path(__file__).parent -class ReactWidget(anywidget.AnyWidget): +class Widget(anywidget.AnyWidget): """TODO: Add docstring here""" _model_name = Unicode("ReactModel").tag(sync=True) @@ -27,11 +31,17 @@ class ReactWidget(anywidget.AnyWidget): _view_name = Unicode("ReactView").tag(sync=True) _view_module = Unicode(module_name).tag(sync=True) _view_module_version = Unicode(module_version).tag(sync=True) - value = Any(None, allow_none=True).tag(sync=True) - debug = Bool(False).tag(sync=True) - name = Unicode(None, allow_none=True).tag(sync=True) - react_version = Int(18).tag(sync=True) + props = Dict({}, allow_none=True).tag(sync=True) + children = List(t.cast(t.List[t.Union[Widget, str]], [])).tag(sync=True, **widget_serialization) + + # this stays on the python side + events = Dict({}) + # this is send of the frontend (keys of events) _event_names = List(Unicode(), allow_none=True).tag(sync=True) + _debug = Bool(False).tag(sync=True) + _type = Unicode(None, allow_none=True).tag(sync=True) + _module = Unicode(None, allow_none=True).tag(sync=True) + _react_version = Int(18).tag(sync=True) _cdn = Unicode("https://esm.sh/").tag _import_map = Dict({}).tag(sync=True) _import_map_default = { @@ -42,9 +52,16 @@ class ReactWidget(anywidget.AnyWidget): }, "scopes": {}, } - _esm = HERE / Path("basic.tsx") + _esm = "" + # _esm = HERE / Path("basic.tsx") def __init__(self, **kwargs) -> None: + _esm = kwargs.pop("_esm", None) + if _esm is not None: + extra_traits = {} + if isinstance(_esm, str): + extra_traits["_esm"] = Unicode(str(_esm)).tag(sync=True) + self.add_traits(**extra_traits) _import_map = kwargs.pop("_import_map", {}) _import_map = { "imports": {**self._import_map_default["imports"], **_import_map.get("imports", {})}, @@ -52,23 +69,46 @@ def __init__(self, **kwargs) -> None: } kwargs["_import_map"] = _import_map _ignore = ["on_msg", "on_displayed", "on_trait_change", "on_widget_constructed"] - _event_names = [ - method_name[3:] - for method_name in dir(self) - if method_name.startswith("on_") and method_name not in _ignore - ] - super().__init__(**{"_event_names": _event_names, **kwargs}) + events = kwargs.pop("events", {}) + for method_name in dir(self): + if method_name.startswith("event_") and method_name not in _ignore: + event_name = method_name[len("event_") :] + method = getattr(self, method_name) + if method_name not in events: + events[event_name] = method + _event_names = list(events) + super().__init__(**{"_event_names": _event_names, "events": events, **kwargs}) self.on_msg(self._handle_event) def _handle_event(self, _, content, buffers): if "event_name" in content.keys(): event_name = content.get("event_name", "") data = content.get("data", {}) - method = getattr(self, "on_" + event_name) + event_hander = self.events.get(event_name, None) + if event_hander is None: + return if "data" not in content: - method() + event_hander() else: if buffers: - method(data, buffers) + event_hander(data, buffers) else: - method(data) + event_hander(data) + + @observe("events") + def _events(self, change): + self.event_names = list(change["new"].keys()) + + +class ValueWidget(Widget, ValueWidgetClassic): + # the ValueWidget from ipywidgets does not add sync=True to the value trait + value = Any(help="The value of the widget.").tag(sync=True) + + +# this is deprecated +class ReactWidget(ValueWidget): + def __init__(self, **kwargs) -> None: + warnings.warn( + "ReactWidget is deprecated, use Widget or ValueWidget instead", DeprecationWarning + ) + super().__init__(**kwargs) diff --git a/src/utils.ts b/src/utils.ts index e88c013..6ef7c64 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -81,3 +81,35 @@ export async function loadScript(type: string, src: string) { }; }); } + +// based on https://stackoverflow.com/a/58416333/5397207 +function pickSerializable(object: any, depth = 0, max_depth = 2) { + // change max_depth to see more levels, for a touch event, 2 is good + if (depth > max_depth) return "Object"; + + const obj: any = {}; + for (let key in object) { + let value = object[key]; + if (value instanceof Window) value = "Window"; + else if (value && value.getModifierState) + value = pickSerializable(value, depth + 1, max_depth); + else { + // test if serializable + try { + JSON.stringify(value); + } catch (e) { + value = "Object"; + } + } + obj[key] = value; + } + + return obj; +} + +export function eventToObject(event: any) { + if (event instanceof Event || (event && event.getModifierState)) { + return pickSerializable(event); + } + return event; +} diff --git a/src/widget.tsx b/src/widget.tsx index 78c5805..a2ba660 100644 --- a/src/widget.tsx +++ b/src/widget.tsx @@ -2,9 +2,12 @@ // Distributed under the terms of the Modified BSD License. import { + WidgetModel, DOMWidgetModel, DOMWidgetView, ISerializers, + unpack_models, + WidgetView, } from "@jupyter-widgets/base"; import * as React from "react"; @@ -13,12 +16,11 @@ import * as ReactJsxRuntime from "react/jsx-runtime"; import * as ReactReconcilerContants from "react-reconciler/constants"; import * as ReactReconciler from "react-reconciler"; import * as ReactDOM from "react-dom"; - // @ts-ignore import * as ReactDOMClient from "react-dom/client"; // @ts-ignore import "../css/widget.css"; -import { expose, loadScript, setUpMuiFixModule } from "./utils"; +import { eventToObject, expose, loadScript, setUpMuiFixModule } from "./utils"; import { MODULE_NAME, MODULE_VERSION } from "./version"; // import * as Babel from '@babel/standalone'; // TODO: find a way to ship es-module-shims with the widget @@ -27,6 +29,23 @@ import { MODULE_NAME, MODULE_VERSION } from "./version"; import { transform } from "sucrase"; import { ErrorBoundary } from "./components"; import { Root } from "react-dom/client"; +import { ModelDestroyOptions } from "backbone"; + +declare function importShim( + specifier: string, + parentUrl?: string, +): Promise<{ default: Default } & Exports>; + +declare namespace importShim { + const resolve: (id: string, parentURL?: string) => string; + const addImportMap: (importMap: Partial) => void; + const getImportMap: () => any; +} + +// interface Window { +// esmsInitOptions?: any; +// importShim: typeof importShim; +// } // @ts-ignore // const react16Code = require('!!raw-loader!./react16.js'); @@ -66,13 +85,13 @@ function autoExternalReactResolve( if (!shipsWith && !alreadyPatched && !isBlob) { id = id + "?external=react,react-dom"; } - // console.log("resolve", id, parentUrl, resolve) return resolve(id, parentUrl); } // @ts-ignore window.esmsInitOptions = { shimMode: true, + mapOverrides: true, resolve: ( id: string, parentUrl: string, @@ -104,6 +123,28 @@ function ensureReactSetup(version: number) { } } +const widgetToReactElement = async ( + widget: WidgetModel, + rootView: WidgetView | null = null, +) => { + const WidgetRenderHOC = (widget: WidgetModel) => { + return () => { + return
widget placeholder
; + }; + }; + if (widget instanceof ReactModel) { + const ChildComponent: any = await widget.component; + const el = ; + return el; + } else if (typeof widget === "string") { + return widget; + } else { + const ChildComponent = WidgetRenderHOC(widget); + const el = ; + return el; + } +}; + export class ReactModel extends DOMWidgetModel { defaults() { return { @@ -114,7 +155,6 @@ export class ReactModel extends DOMWidgetModel { _view_name: ReactModel.view_name, _view_module: ReactModel.view_module, _view_module_version: ReactModel.view_module_version, - value: null, }; // TODO: ideally, we only compile code in the widget model, but the react hooks are // super convenient. @@ -122,21 +162,74 @@ export class ReactModel extends DOMWidgetModel { static serializers: ISerializers = { ...DOMWidgetModel.serializers, + children: { deserialize: unpack_models as any }, }; - static model_name = "ReactModel"; - static model_module = MODULE_NAME; - static model_module_version = MODULE_VERSION; - static view_name = "ReactView"; // Set to null if no view - static view_module = MODULE_NAME; // Set to null if no view - static view_module_version = MODULE_VERSION; -} - -export class ReactView extends DOMWidgetView { - private root: Root | null = null; - - render() { - this.el.classList.add("jupyter-react-widget"); + initialize(attributes: any, options: any): void { + super.initialize(attributes, options); + this.component = new Promise((resolve, reject) => { + this.resolveComponent = resolve; + this.rejectComponent = reject; + }); + this.queue = Promise.resolve(); + this.on("change:_import_map", async () => { + this.enqueue(async () => { + // chain these updates, so they are executed in order + await this.updateComponentToWrap(); + }); + }); + this.on("change:_esm", async () => { + this.enqueue(async () => { + this.compileCode(); + await this.updateComponentToWrap(); + }); + }); + this.on("change:_module change:_type", async () => { + this.enqueue(async () => { + await this.updateImportMap(); + await this.updateComponentToWrap(); + }); + }); + this._initialSetup(); + } + enqueue(fn: () => Promise) { + // this makes sure that callbacks and _initialSetup are executed in order + // and not in parallel, which can lead to race conditions + this.queue = this.queue.then(async () => { + await fn(); + }); + return this.queue; + } + async _initialSetup() { + await this.enqueue(async () => { + await this.updateImportMap(); + this.compileCode(); + try { + let component: any = await this.createWrapperComponent(); + this.resolveComponent(component); + } catch (e) { + console.error(e); + this.rejectComponent(e); + } + }); + // await this.createComponen(); + } + async updateImportMap() { + await ensureImportShimLoaded(); + const reactImportMap = ensureReactSetup(this.get("_react_version")); + const importMapWidget = this.get("_import_map"); + const importMap = { + imports: { + ...reactImportMap, + ...importMapWidget["imports"], + }, + scopes: { + ...importMapWidget["scopes"], + }, + }; + importShim.addImportMap(importMap); + } + compileCode() { // using babel is a bit of an art, so leaving this code for if we // want to switch back to babel. However, babel is very large compared // to sucrase @@ -147,47 +240,213 @@ export class ReactView extends DOMWidgetView { // ] // }); // Babel.registerPlugin("importmap", pluginImport()); + const code = this.get("_esm"); + this.compileError = null; + if (!code) { + this.compiledCode = null; + return; + } + if (this.get("_debug")) { + console.log("original code:\n", code); + } + try { + // using babel: + // return Babel.transform(code, { presets: ["react", "es2017"], plugins: ["importmap"] }).code; + // using sucrase: + this.compiledCode = transform(code, { + transforms: ["jsx", "typescript"], + filePath: "test.tsx", + }).code; + if (this.get("_debug")) { + console.log("compiledCode:\n", this.compiledCode); + } + } catch (e) { + console.error(e); + this.compileError = e; + } + } + async updateComponentToWrap() { + try { + let component: any = await this.createComponentToWrap(); + this.currentComponentToWrapOrError = component; + this.trigger("component", component); + } catch (e) { + console.error(e); + this.trigger("component", e); + } + } + async createComponentToWrap() { + let moduleName = this.get("_module"); + let type = this.get("_type"); + if (this.compileError) { + return () =>
{this.compileError.message}
; + } else { + let module: any = null; + // html element like div or button + if (!moduleName && !this.compiledCode && type) { + return type; + } + + if (!this.compiledCode && !moduleName && !type) { + return () => ( +
no component provided, pass _esm, or _module and _type
+ ); + } else if (this.compiledCode) { + if (this.codeUrl) { + URL.revokeObjectURL(this.codeUrl); + } + this.codeUrl = URL.createObjectURL( + new Blob([this.compiledCode], { type: "text/javascript" }), + ); + module = await importShim(this.codeUrl); + if (!module) { + return () =>
error loading module
; + } + } else { + module = await importShim(moduleName); + if (!module) { + return () =>
no module found with name {moduleName}
; + } + } + let component = module[type || "default"]; + if (!component) { + if (type) { + return () => ( +
+ no component found in module {moduleName} (with name {type}) +
+ ); + } else { + return () => ( +
+ no component found in module {moduleName} (it should be exported + as default) +
+ ); + } + } else { + if (this.compiledCode) { + const needsMuiFix = this.compiledCode.indexOf("@mui") !== -1; + if (needsMuiFix) { + let muiFix = await setUpMuiFixModule(); + const componentToWrap = component; + // console.log("muiFix", muiFix); + // @ts-ignore + component = (props: any) => { + // console.log("component wrapper fix", props) + // return componentToWrap(props); + return muiFix.styleWrapper(componentToWrap(props)); + }; + } + } + return component; + } + } + } + async createWrapperComponent() { + // we wrap the component in a wrapper that puts in all the props from the + // widget model, and handles events, etc + const childrenToReactElement = async (view: any) => { + let childrenWidgets: Array = this.get("children"); + return await Promise.all( + childrenWidgets.map( + async (child: any) => await widgetToReactElement(child, view), + ), + ); + }; + + let initialChildren = await childrenToReactElement(null); + // const resolveFormatters = async () => { + // let formatterDict = this.get("formatters") || {}; + // let formatterModules : any = {}; + // for (const key of Object.keys(formatterDict)) { + // // @ts-ignore + // let module = await importShim(formatterDict[key]); + // formatterModules[key] = module; + // } + // return formatterModules; + // } + + // let formatterModules = await resolveFormatters(); + // console.log("formatterModules", formatterModules); + + try { + this.currentComponentToWrapOrError = await this.createComponentToWrap(); + } catch (e) { + this.currentComponentToWrapOrError = e; + } - // @ts-ignore - // const React : any = { - // 16: React16, - // 18: React18, - // }[this.model.get("react_version")]; + const isSpecialProp = (key: string) => { + const specialProps = [ + "children", + "props", + "tabbable", + "layout", + "tooltip", + ]; + if (specialProps.find((x) => x === key)) { + return true; + } + if (key.startsWith("_")) { + return true; + } + return false; + }; - const Component = () => { - // @ts-ignore - // @ts-ignore - const [_, setCounter] = useState(0); + const WrapperComponent = ({ view, ...parentProps }: { view: any }) => { + const [component, setComponent] = useState( + () => this.currentComponentToWrapOrError, + ); + React.useEffect(() => { + this.listenTo(this, "component", (component) => { + console.log("set component", component); + setComponent(() => component); + }); + return () => { + this.stopListening(this, "component"); + }; + }, []); + const setForceRerenderCounter = useState(0)[1]; const forceRerender = () => { - setCounter((x) => x + 1); + console.log( + "force rerender", + name, + this.get("props"), + this.previous("props"), + ); + setForceRerenderCounter((x) => x + 1); + }; + const [children, setChildren] = useState(initialChildren); + const updateChildren = () => { + console.log("update children"); + (async () => { + setChildren(await childrenToReactElement(view)); + })(); }; useEffect(() => { - this.listenTo(this.model, "change", forceRerender); - }, []); - - const compiledCode: string | Error = React.useMemo(() => { - const code = this.model.get("_esm"); - if (this.model.get("debug")) { - console.log("original code:\n", code); - } - try { - // using babel: - // return Babel.transform(code, { presets: ["react", "es2017"], plugins: ["importmap"] }).code; - // using sucrase: - let compiledCode = transform(code, { - transforms: ["jsx", "typescript"], - filePath: "test.tsx", - }).code; - if (this.model.get("debug")) { - console.log("compiledCode:\n", compiledCode); + this.listenTo(this, "change:props", forceRerender); + this.listenTo(this, "change:children", updateChildren); + for (const key of Object.keys(this.attributes)) { + if (isSpecialProp(key)) { + continue; } - return compiledCode; - } catch (e) { - return e; + this.listenTo(this, `change:${key}`, updateChildren); } - }, [this.model.get("_esm")]); - const props: any = {}; - for (const event_name of this.model.attributes["_event_names"]) { + + updateChildren(); // how can we avoid that we have to re-render to pass in the view + return () => { + this.stopListening(this, "change:props", forceRerender); + this.stopListening(this, "change:children", updateChildren); + for (const key of Object.keys(this.attributes)) { + if (isSpecialProp(key)) { + continue; + } + this.stopListening(this, `change:${key}`, updateChildren); + } + }; + }, []); + const events: any = {}; + for (const event_name of this.attributes["_event_names"]) { const handler = (value: any, buffers: any) => { if (buffers) { const validBuffers = @@ -197,121 +456,103 @@ export class ReactView extends DOMWidgetView { buffers = undefined; } } - this.model.send( - { event_name, data: value }, - this.model.callbacks(this), + const saveValue = eventToObject(value); + console.log("sending", event_name, saveValue, view); + this.send( + { event_name, data: saveValue }, + this.callbacks(view), buffers, ); }; - props["on_" + event_name] = handler; + events[event_name] = handler; } - for (const key of Object.keys(this.model.attributes)) { - props[key] = this.model.get(key); - props["on_" + key] = (value: any) => { - console.warn(`on_${key} is deprecated, use set_${key} instead`); - this.model.set(key, value); - this.touch(); - }; - props["set_" + key] = (value: any) => { - this.model.set(key, value); - this.touch(); + // React.createElement('div', {"aria-activedescendant": "foo"}}) + //
+ const modelProps = { ...this.get("props") }; + // for (const key of Object.keys(modelProps)) { + // if(formatterModules[key]) { + // modelProps[key] = formatterModules[key].py2js(modelProps[key]); + // } + // } + // console.log("children", children); + const childrenProps = children.length > 0 ? { children: children } : {}; + // useEffect(() => { + // // force render every 2 seconds + // const interval = setInterval(() => { + // forceRerender(); + // }, 2000); + // return () => { + // clearInterval(interval); + // } + // }, []); + //const [r//] + const backboneProps: any = {}; + for (const key of Object.keys(this.attributes)) { + if (isSpecialProp(key)) { + continue; + } + backboneProps[key] = this.get(key); + backboneProps["set" + key.charAt(0).toUpperCase() + key.slice(1)] = ( + value: any, + ) => { + this.set(key, value); + // this.touch(); + this.save_changes(this.callbacks(view)); }; } - const [scope, setScope] = React.useState(null as any | Error); - const [muiFix, setMuiFix] = React.useState(null as any | Error); - React.useEffect(() => { - let url: string | null = null; - (async () => { - if (compiledCode instanceof Error) { - setScope(compiledCode); - return; - } - const reactImportMap = ensureReactSetup( - this.model.get("react_version"), - ); - await ensureImportShimLoaded(); - let finalCode = compiledCode; - // @ts-ignore - const importMapWidget = this.model.get("_import_map"); - const importMap = { - imports: { - ...reactImportMap, - ...importMapWidget["imports"], - }, - scopes: { - ...importMapWidget["scopes"], - }, - }; - // @ts-ignore - importShim.addImportMap(importMap); - const needsMuiFix = compiledCode.indexOf("@mui") !== -1; - if (needsMuiFix) { - setMuiFix(await setUpMuiFixModule()); - } - url = URL.createObjectURL( - new Blob([finalCode], { type: "text/javascript" }), - ); - try { - // @ts-ignore - let module = await importShim(url); - let name = this.model.get("name"); - if (name && name.length > 0) { - // @ts-ignore - importShim.addImportMap({ imports: { [name]: url } }); - } - setScope(module); - } catch (e) { - setScope(e); - } - })(); - return () => { - if (url) { - URL.revokeObjectURL(url); - } - }; - }, [compiledCode]); - - if (!scope) { - return
Loading...
; - } else { - if (scope instanceof Error) { - return
{scope.message}
; - } else { - if (scope.default === undefined) { - return
Missing default component
; - } else { - if (this.model.get("debug")) { - console.log("props", props); - } - // @ts-ignore - let el = React.createElement(scope.default, props); - // check if @mui string is in compiledCode - // if so, we need to wrap the element in a style wrapper - // @ts-ignore - const needsMuiFix = compiledCode.indexOf("@mui") !== -1; - if (this.model.get("debug")) { - console.log("needsMuiFix", needsMuiFix); - } - if (needsMuiFix) { - el = muiFix.styleWrapper(el); - } - return el; - } - } + const props = { + ...modelProps, + ...backboneProps, + ...parentProps, + ...events, + ...childrenProps, + }; + console.log("props", props, children, component); + if (component instanceof Error) { + throw component; } + return React.createElement(component, props); }; - if (this.model.get("react_version") === 18) { - this.root = ReactDOMClient.createRoot(this.el); - this.root.render( - - - , - ); - } else { - // @ts-ignore - // ReactDOM16.render(, this.el); + return WrapperComponent; + } + destroy(options?: ModelDestroyOptions | undefined): false | JQueryXHR { + if (this.codeUrl) { + URL.revokeObjectURL(this.codeUrl); } + return super.destroy(options); + } + public component: Promise; + private resolveComponent: (value: any) => void; + private rejectComponent: (value: any) => void; + private compiledCode: string | null = null; + private compileError: any | null = null; + private codeUrl: string | null = null; + // this used so that the WrapperComponent can be rendered synchronously, + private currentComponentToWrapOrError: any = null; + private queue: Promise; + + static model_name = "ReactModel"; + static model_module = MODULE_NAME; + static model_module_version = MODULE_VERSION; + static view_name = "ReactView"; // Set to null if no view + static view_module = MODULE_NAME; // Set to null if no view + static view_module_version = MODULE_VERSION; +} + +export class ReactView extends DOMWidgetView { + private root: Root | null = null; + + async render() { + this.el.classList.add("jupyter-react-widget"); + // using babel is a bit of an art, so leaving this code for if we + this.root = ReactDOMClient.createRoot(this.el); + const Component: any = await (this.model as ReactModel).component; + this.root.render( + + + , + ); } remove() { diff --git a/tests/ui/event_test.py b/tests/ui/event_test.py index 25d1b6d..49d9148 100644 --- a/tests/ui/event_test.py +++ b/tests/ui/event_test.py @@ -16,7 +16,7 @@ class ButtonWithHandler(ipyreact.ReactWidget): }; """ - def on_click(self): + def event_on_click(self): self.label = "Clicked" diff --git a/tests/ui/jupyter_test.py b/tests/ui/jupyter_test.py index 2cd88d4..9803868 100644 --- a/tests/ui/jupyter_test.py +++ b/tests/ui/jupyter_test.py @@ -12,8 +12,8 @@ class Counter(ipyreact.ReactWidget): _esm = """ import * as React from "react"; - export default function({value, on_value, debug}) { - return };""" diff --git a/tests/ui/library_test.py b/tests/ui/library_test.py index c4319ff..4b22611 100644 --- a/tests/ui/library_test.py +++ b/tests/ui/library_test.py @@ -7,8 +7,8 @@ import Button from '@mui/material/Button'; import * as React from "react"; -export default function({value, set_value, debug}) { - return };