posit-dev / py-shinywidgets

Render ipywidgets inside a PyShiny app
MIT License
46 stars 5 forks source link

Add support for ipyreact #160

Open machow opened 1 month ago

machow commented 1 month ago

Description

Currently, I'm using ipyreact to create interactive tables in reactable-py. ipyreact is built on Anywidget, and I noticed there's quak support here, but I was unable to get ipyreact react to work šŸ˜“ .

What I Did

Note that in the express app below, I could see a div prepared for the widget, but no content:

from shiny.express import ui, render
from ipyreact import Widget
from shinywidgets import render_widget

@render_widget
def out():
    return Widget(_type="button", children=["click me"], events={"onClick": print})

I double checked some of the shinywidgets._dependency pieces, and it looks like it's finding / loading the necessary javascript. However, I'm not what might be different about ipyreact here šŸ˜¬

edit: updated example to import ipyreact first. importing after shiny widgets was causing an error (see #163)

cpsievert commented 2 weeks ago

After investigating this with @machow, we discovered ipyreact has it's own way of importing dependencies that more or less you're targetting a Jupyter environment (ipyreact.define_module). As long as that is avoided, ipyreact widgets do seem to render OK with shinywidgets.

machow commented 2 weeks ago

I did some more digging, and wonder if this issue is related to #163.

It seems like the challenge is this:

Here's an example app, where explicitly rendering a Module representing reactable's javascript code allows it to be rendered.

from shiny.express import ui
from reactable import Reactable
from reactable.data import cars_93
from shinywidgets import render_widget
import ipyreact

# setup ----

from pathlib import Path
from importlib_resources import files

STATIC_FILES = files("reactable.static")
ui.include_css(Path(str(files("reactable.static") / "reactable-py.esm.css")))

# render the module in order to create the dependency -----
# normally this is just defined inside reactable-py, and picked up automatically
# but explicitly rendering it registers dependency (but has side-effect of outputting UI)

@render_widget
def out2():
    mod = ipyreact.define_module("reactable", Path(str(STATIC_FILES / "reactable-py.esm.js")))
    return mod

# table we want to render ----

@render_widget
def out():
    from shinywidgets._dependencies import jupyter_extension_destination, jupyter_extension_path

    Reactable(cars_93).to_widget()

I wonder if part of the issue with #163 is that ipyreact is instantiating widgets on import, which jupyter (and by extension quarto) end up using (these seem sort of like "shadow widgets" or something?).

machow commented 2 weeks ago

@maartenbreddels is there any chance you might be able to help give a sense for how ipyreact handles the Module widgets created by ipyreact.define_module?

I'm trying to figure out what triggers their "rendering". I noticed that ipyreact.Widget has all the dependency Module names as the default for ipyreact.Widget._dependencies, but I'm not sure how those get picked up and rendered.

I'm loving ipyreact, and super close to getting reactable-py out the door! It works really nicely in solara, so I'm guessing there's some small piece I've overlooked here šŸ˜“.

maartenbreddels commented 2 weeks ago

Iā€™m without a keyboard, but I can give some short hints. Solara avoids the global widgets by monkey patching define_module: https://github.com/widgetti/solara/blob/master/solara/server/esm.py https://github.com/widgetti/solara/blob/master/solara/server/patch.py (see patch_ipyreact) https://github.com/widgetti/solara/blob/4cfc3a4adb95271db80249ddcfaaf78491cc093b/solara/server/app.py#L375

Maybe using the same code in shiny will also work. Good luck, I can elaborate more next week when Iā€™m behind a keyboard again.

Regards,

Maarten

machow commented 2 weeks ago

Ah, thanks -- this is helpful to see! Looking closer, I'm realizing that maybe the key here is what ipywidgets.Widget does on initialization. It looks like...

The issue comes then, because this is never run by py-shinywidgets, since...

It sounds like, once it's ready, shinywidgets should run through all the widgets it no-op'd and run their on_widget_constructed hook (or something?).

I.e.

machow commented 2 weeks ago

Here's a minimal example using shiny's @reactive.calc to ensure the Module reactable-py needs runs its on construction hook, but without rendering it into the shiny app. Sorry this is so chaotic, but this was super helpful for wrapping my head around the ipywidgets lifecycle šŸ˜ :

from shiny.express import ui
from shiny import reactive
from reactable import Reactable
from reactable.data import cars_93
from shinywidgets import render_widget
import ipyreact

from pathlib import Path
from importlib_resources import files

STATIC_FILES = files("reactable.static")

ui.include_css(Path(STATIC_FILES / "reactable-py.esm.css"))

# Reactive calc: define the reactable Module -----

@reactive.calc
def out2():
    # Module needs to be created here, because shiny won't run the on construction hook, until the
    # shiny app is running, and that is after all the code is initially run...
    mod = ipyreact.define_module("reactable", Path(str(STATIC_FILES / "reactable-py.esm.js")))
    return mod

# Uses the calc before rendering, to ensure the Module is loaded ----

@render_widget
def out():
    out2()
    return Reactable(cars_93).to_widget()