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 };