reflex-dev / reflex

πŸ•ΈοΈ Web apps in pure Python 🐍
https://reflex.dev
Apache License 2.0
18k stars 997 forks source link

rx.tooltip doesn't work with rx.button when that rx.button has additional properties #3278

Open greenseeker opened 1 month ago

greenseeker commented 1 month ago

Describe the bug Tooltips are not displayed for (icon_)buttons once that button has addition properties specified.

To Reproduce Use the following code in a page. Hovering over the icon_button will not display the tooltip.

rx.tooltip(
    rx.icon_button(
        "home", 
        on_click=rx.redirect("/"), 
        disabled=(AppState.router.page.path=="/")
    ), 
    content="Go to homepage"
)

If you comment out the on_click and disabled lines, then the tooltip works.

rx.tooltip(
    rx.icon_button(
        "home", 
#        on_click=rx.redirect("/"), 
#        disabled=(AppState.router.page.path=="/")
    ), 
    content="Go to homepage"
)

Expected behavior The tooltip should still be displayed.

Specifics (please complete the following information):

Lendemor commented 1 month ago

Thanks for the report.

Seems to be caused by the level of isolation we add when components need to use State related stuff. With

rx.tooltip(
    rx.icon_button(
        "home", 
        disabled=True
    ), 
    content="Go to homepage"
)

the tooltip will show up properly.

Need to investigate on Radix side, it might be a bug from them.

Lendemor commented 1 month ago

Trimmed down code that reproduce the issue :

/** @jsxImportSource @emotion/react */

import { Fragment } from "react";
import {
  Box as RadixThemesBox,
  Tooltip as RadixThemesTooltip,
} from "@radix-ui/themes";

export function Box_f821d43fc5d8628e6aa9573e5ba02f7f() {
  return <RadixThemesBox>{`home`}</RadixThemesBox>;
}

export default function Component() {
  return (
    <Fragment>
      <RadixThemesTooltip content={`Go to homepage`}>
        <Box_f821d43fc5d8628e6aa9573e5ba02f7f />
      </RadixThemesTooltip>
    </Fragment>
  );
}

Moving component directly in the same function where Tooltip is used make the code work:

/** @jsxImportSource @emotion/react */

import { Fragment } from "react";
import {
  Box as RadixThemesBox,
  Tooltip as RadixThemesTooltip,
} from "@radix-ui/themes";

export default function Component() {
  return (
    <Fragment>
      <RadixThemesTooltip content={`Go to homepage`}>
        <RadixThemesBox>{`home`}</RadixThemesBox>
      </RadixThemesTooltip>
    </Fragment>
  );
}
greenseeker commented 1 month ago

After posting I went looking for a workaround with different components, but it seems to impact anything that would appear on hover, not just rx.tooltip.

greenseeker commented 1 month ago

If there's any workaround for this, please let me know, even if it's hacky. My app is just a mockup, so I'm not worried about it being done "right". Even if there's some way to set the alt property and use default browser tooltips, that'd be good.

masenf commented 1 month ago

If there's any workaround for this, please let me know

from reflex.components.component import MemoizationLeaf
from reflex.components.base.fragment import Fragment

class IntegralFragment(Fragment, MemoizationLeaf):
    pass

...

            IntegralFragment.create(
                rx.tooltip(
                    rx.icon_button(
                        "home", 
                        on_click=rx.redirect("/"), 
                        disabled=(State.router.page.path=="/")
                    ), 
                    content="Go to homepage"
                ),
            ),

Don't ask me why this works, needs further investigation. But it does appear to workaround the problem.

masenf commented 1 month ago

Okay this actually helped a lot https://github.com/radix-ui/primitives/discussions/1166

Turns out that Reflex's StatefulComponent memoization is not handling ref and prop forwarding, which Radix is depending on.

If i change the code to be like this

export const Button_4f9c226eb6db5a68f02ace30e6d02ba7 = forwardRef((props, forwardedRef) => {
  const state = useContext(StateContexts.state)

  return (
    <button ref={forwardedRef} {...props}>
  {`fooey ${state.router.session.client_token}`}
</button>
  )
})

Then <Button_4f9c226eb6db5a68f02ace30e6d02ba7 /> can work as a child of the tooltip.

I wonder if it makes sense to eventually structure all of the memoized components in this way to maximize compatibility.

greenseeker commented 1 month ago

@masenf what's that workaround doing?

masenf commented 1 month ago

what's that workaround doing?

The workaround is making it so that child of the tooltip does not get memoized separately. The IntegralFragment becomes its own React component with the state context of all its children inside.

abulvenz commented 1 month ago

I have another minimal example, which causes the same error and @masenf 's workaround makes it work.

import reflex as rx

class SimpleState(rx.State):
    key: str = "Don't open menu"

@rx.page(route="/page2")
def page2() -> rx.Component:
    return rx.menu.root(
    rx.menu.trigger(
        rx.button(SimpleState.key),
    ),
    rx.menu.content(
        rx.menu.item("Edit", shortcut="⌘ E"),
   ),
)

The menu is rendered correctly, but as the inscription on the button promises, the menu will not open. Even if you change the text to "Open menu".

I can not oversee if this is the exact same problem or a different one. If different, please let me know and I will create a new issue.

masenf commented 1 month ago

is the exact same problem

yes, i believe it's the same issue. Radix is wanting to pass a ref down into the component wrapped by the trigger/tooltip/close/etc and the memoized StatefulComponent code just does not support it, so it doesn't inherit the special behavior.

I think the fix is relatively clear, I just haven't had time to put it in and test it.

abulvenz commented 1 week ago

@masenf I just encountered it again (see below). Even with the workaround (hopefully applied correctly (I'm not a react-expert)) it doesn't work (OK, the error is gone, but the click not propagated). Sorry for the messy example, I tried to isolate it as good as possible.


# index.py in fresh sidebar app
from reflex_custom_components_playground.templates import template  

import reflex as rx
from reflex.components.component import MemoizationLeaf
from reflex.components.base.fragment import Fragment

class IntegralFragment(Fragment, MemoizationLeaf):
    pass

class IndexState(rx.State):
    icon: str = "home"

    def set_skull(self) -> None:
        self.icon = "skull"

    def set_trash(self) -> None:
        self.icon = "trash"

@template(route="/", title="Home")
def index() -> rx.Component:

    return rx.vstack(
        rx.text(IndexState.icon),
        rx.table.root(
            rx.table.header(
                rx.table.row(
                    rx.table.cell(),
                    rx.table.column_header_cell(
                        "Delete without asking",
                        IntegralFragment.create(
                            rx.tooltip(
                                rx.icon(
                                    tag="skull",
                                    on_click=IndexState.set_skull,
                                ),
                                content="Skull icon",
                            ),
                        ),
                    ),
                    rx.table.column_header_cell(
                        "Delete asking politely",
                        rx.dialog.root(
                            rx.dialog.trigger(
                                IntegralFragment.create(
                                    rx.tooltip(
                                        rx.icon(
                                            tag="trash-2",
                                            # on_click=IndexState.set_trash,
                                        ),
                                        content="Trash icon",
                                    ),
                                ),
                            ),
                            rx.dialog.content(
                                rx.dialog.title("action.name"),
                                rx.dialog.description(
                                    "action.confirmation_description"
                                ),
                                rx.dialog.close(
                                    rx.hstack(
                                        rx.button(
                                            "action.name",
                                            size="3",
                                            id="confirm",
                                            background_color="green",
                                            on_click=IndexState.set_trash,  # type: ignore[reportArgumentType]
                                        ),
                                        rx.button(
                                            "Cancel",
                                            size="3",
                                            background_color="red",
                                            id="abort",
                                        ),
                                    )
                                ),
                            ),
                        ),
                    ),
                ),
            ),
            rx.table.body(
                rx.table.row(
                    rx.table.cell("John Doe"),
                    rx.table.cell(
                        "Delete without asking",
                        IntegralFragment.create(
                            rx.tooltip(
                                rx.icon(
                                    tag="skull",
                                    on_click=IndexState.set_skull,
                                ),
                                content="Skull icon",
                            ),
                        ),
                    ),
                    rx.table.cell(
                        "Delete asking politely",
                        rx.dialog.root(
                            rx.dialog.trigger(
                                IntegralFragment.create(
                                    rx.tooltip(
                                        rx.icon(
                                            tag="trash-2",
                                            # on_click=IndexState.set_trash,
                                        ),
                                        content="Trash icon",
                                    ),
                                ),
                            ),
                            rx.dialog.content(
                                rx.dialog.title("action.name"),
                                rx.dialog.description(
                                    "action.confirmation_description"
                                ),
                                rx.dialog.close(
                                    rx.hstack(
                                        rx.button(
                                            "action.name",
                                            size="3",
                                            id="confirm",
                                            background_color="green",
                                            on_click=IndexState.set_trash,  # type: ignore[reportArgumentType]
                                        ),
                                        rx.button(
                                            "Cancel",
                                            size="3",
                                            background_color="red",
                                            id="abort",
                                        ),
                                    )
                                ),
                            ),
                        ),
                    ),
                ),
            ),
        ),
    )