diff --git a/reflex/app.py b/reflex/app.py index 65cb5bfdf..6e66257b4 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -75,6 +75,7 @@ from reflex.components.core.client_side_routing import ( from reflex.components.core.sticky import sticky from reflex.components.core.upload import Upload, get_upload_dir from reflex.components.radix import themes +from reflex.components.sonner.toast import toast from reflex.config import ExecutorType, environment, get_config from reflex.event import ( _EVENT_FIELDS, @@ -84,7 +85,6 @@ from reflex.event import ( EventType, IndividualEventType, get_hydrate_event, - window_alert, ) from reflex.model import Model, get_db_status from reflex.page import DECORATED_PAGES @@ -144,7 +144,7 @@ def default_backend_exception_handler(exception: Exception) -> EventSpec: EventSpec: The window alert event. """ - from reflex.components.sonner.toast import Toaster, toast + from reflex.components.sonner.toast import toast error = traceback.format_exc() @@ -155,18 +155,16 @@ def default_backend_exception_handler(exception: Exception) -> EventSpec: if is_prod_mode() else [f"{type(exception).__name__}: {exception}.", "See logs for details."] ) - if Toaster.is_used: - return toast( - "An error occurred.", - level="error", - description="
".join(error_message), - position="top-center", - id="backend_error", - style={"width": "500px"}, - ) - else: - error_message.insert(0, "An error occurred.") - return window_alert("\n".join(error_message)) + + return toast( + "An error occurred.", + level="error", + fallback_to_alert=True, + description="
".join(error_message), + position="top-center", + id="backend_error", + style={"width": "500px"}, + ) def extra_overlay_function() -> Optional[Component]: @@ -414,7 +412,7 @@ class App(MiddlewareMixin, LifespanMixin): ] = default_backend_exception_handler # Put the toast provider in the app wrap. - bundle_toaster: bool = True + toaster: Component | None = dataclasses.field(default_factory=toast.provider) @property def api(self) -> FastAPI | None: @@ -1100,10 +1098,6 @@ class App(MiddlewareMixin, LifespanMixin): should_compile = self._should_compile() if not should_compile: - if self.bundle_toaster: - from reflex.components.sonner.toast import Toaster - - Toaster.is_used = True with console.timing("Evaluate Pages (Backend)"): for route in self._unevaluated_pages: console.debug(f"Evaluating page: {route}") @@ -1133,20 +1127,6 @@ class App(MiddlewareMixin, LifespanMixin): + adhoc_steps_without_executor, ) - if self.bundle_toaster: - from reflex.components.component import memo - from reflex.components.sonner.toast import toast - - internal_toast_provider = toast.provider() - - @memo - def memoized_toast_provider(): - return internal_toast_provider - - toast_provider = Fragment.create(memoized_toast_provider()) - - app_wrappers[(1, "ToasterProvider")] = toast_provider - with console.timing("Evaluate Pages (Frontend)"): performance_metrics: list[tuple[str, float]] = [] for route in self._unevaluated_pages: @@ -1207,6 +1187,17 @@ class App(MiddlewareMixin, LifespanMixin): # Add the custom components from the page to the set. custom_components |= component._get_all_custom_components() + if (toaster := self.toaster) is not None: + from reflex.components.component import memo + + @memo + def memoized_toast_provider(): + return toaster + + toast_provider = Fragment.create(memoized_toast_provider()) + + app_wrappers[(1, "ToasterProvider")] = toast_provider + # Add the app wraps to the app. for key, app_wrap in self.app_wraps.items(): component = app_wrap(self._state is not None) diff --git a/reflex/components/core/banner.py b/reflex/components/core/banner.py index 1fc631616..c0e88b23e 100644 --- a/reflex/components/core/banner.py +++ b/reflex/components/core/banner.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Optional from reflex import constants +from reflex.components.base.fragment import Fragment from reflex.components.component import Component from reflex.components.core.cond import cond from reflex.components.el.elements.typography import Div @@ -16,7 +17,7 @@ from reflex.components.radix.themes.components.dialog import ( ) from reflex.components.radix.themes.layout.flex import Flex from reflex.components.radix.themes.typography.text import Text -from reflex.components.sonner.toast import Toaster, ToastProps +from reflex.components.sonner.toast import ToastProps, toast_ref from reflex.config import environment from reflex.constants import Dirs, Hooks, Imports from reflex.constants.compiler import CompileVars @@ -90,7 +91,7 @@ def default_connection_error() -> list[str | Var | Component]: ] -class ConnectionToaster(Toaster): +class ConnectionToaster(Fragment): """A connection toaster component.""" def add_hooks(self) -> list[str | Var]: @@ -113,11 +114,11 @@ class ConnectionToaster(Toaster): if environment.REFLEX_DOES_BACKEND_COLD_START.get(): loading_message = Var.create("Backend is starting.") backend_is_loading_toast_var = Var( - f"toast.loading({loading_message!s}, {{...toast_props, description: '', closeButton: false, onDismiss: () => setUserDismissed(true)}},)" + f"toast?.loading({loading_message!s}, {{...toast_props, description: '', closeButton: false, onDismiss: () => setUserDismissed(true)}},)" ) backend_is_not_responding = Var.create("Backend is not responding.") backend_is_down_toast_var = Var( - f"toast.error({backend_is_not_responding!s}, {{...toast_props, description: '', onDismiss: () => setUserDismissed(true)}},)" + f"toast?.error({backend_is_not_responding!s}, {{...toast_props, description: '', onDismiss: () => setUserDismissed(true)}},)" ) toast_var = Var( f""" @@ -138,10 +139,11 @@ setTimeout(() => {{ f"Cannot connect to server: {connection_error}." ) toast_var = Var( - f"toast.error({loading_message!s}, {{...toast_props, onDismiss: () => setUserDismissed(true)}},)" + f"toast?.error({loading_message!s}, {{...toast_props, onDismiss: () => setUserDismissed(true)}},)" ) individual_hooks = [ + Var(f"const toast = {toast_ref};"), f"const toast_props = {LiteralVar.create(props)!s};", "const [userDismissed, setUserDismissed] = useState(false);", "const [waitedForBackend, setWaitedForBackend] = useState(false);", @@ -163,7 +165,7 @@ setTimeout(() => {{ {toast_var!s} }} }} else {{ - toast.dismiss("{toast_id}"); + toast?.dismiss("{toast_id}"); setUserDismissed(false); // after reconnection reset dismissed state }} }} @@ -189,7 +191,6 @@ setTimeout(() => {{ Returns: The connection toaster component. """ - Toaster.is_used = True return super().create(*children, **props) diff --git a/reflex/components/core/banner.pyi b/reflex/components/core/banner.pyi index ce299f443..52fc65787 100644 --- a/reflex/components/core/banner.pyi +++ b/reflex/components/core/banner.pyi @@ -5,10 +5,10 @@ # ------------------------------------------------------ from typing import Any, Dict, Literal, Optional, Union, overload +from reflex.components.base.fragment import Fragment from reflex.components.component import Component from reflex.components.el.elements.typography import Div from reflex.components.lucide.icon import Icon -from reflex.components.sonner.toast import Toaster, ToastProps from reflex.constants.compiler import CompileVars from reflex.event import EventType from reflex.style import Style @@ -41,48 +41,13 @@ class WebsocketTargetURL(Var): def default_connection_error() -> list[str | Var | Component]: ... -class ConnectionToaster(Toaster): +class ConnectionToaster(Fragment): def add_hooks(self) -> list[str | Var]: ... @overload @classmethod def create( # type: ignore cls, *children, - theme: Optional[Union[Var[str], str]] = None, - rich_colors: Optional[Union[Var[bool], bool]] = None, - expand: Optional[Union[Var[bool], bool]] = None, - visible_toasts: Optional[Union[Var[int], int]] = None, - position: Optional[ - Union[ - Literal[ - "bottom-center", - "bottom-left", - "bottom-right", - "top-center", - "top-left", - "top-right", - ], - Var[ - Literal[ - "bottom-center", - "bottom-left", - "bottom-right", - "top-center", - "top-left", - "top-right", - ] - ], - ] - ] = None, - close_button: Optional[Union[Var[bool], bool]] = None, - offset: Optional[Union[Var[str], str]] = None, - dir: Optional[Union[Var[str], str]] = None, - hotkey: Optional[Union[Var[str], str]] = None, - invert: Optional[Union[Var[bool], bool]] = None, - toast_options: Optional[Union[ToastProps, Var[ToastProps]]] = None, - gap: Optional[Union[Var[int], int]] = None, - loading_icon: Optional[Union[Icon, Var[Icon]]] = None, - pause_when_page_is_hidden: Optional[Union[Var[bool], bool]] = None, style: Optional[Style] = None, key: Optional[Any] = None, id: Optional[Any] = None, @@ -110,20 +75,6 @@ class ConnectionToaster(Toaster): Args: *children: The children of the component. - theme: the theme of the toast - rich_colors: whether to show rich colors - expand: whether to expand the toast - visible_toasts: the number of toasts that are currently visible - position: the position of the toast - close_button: whether to show the close button - offset: offset of the toast - dir: directionality of the toast (default: ltr) - hotkey: Keyboard shortcut that will move focus to the toaster area. - invert: Dark toasts in light mode and vice versa. - toast_options: These will act as default options for all toasts. See toast() for all available options. - gap: Gap between toasts when expanded - loading_icon: Changes the default loading icon - pause_when_page_is_hidden: Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked. style: The style of the component. key: A unique key for the component. id: The id for the component. diff --git a/reflex/components/sonner/toast.py b/reflex/components/sonner/toast.py index d1f9464d8..ce52a9ce6 100644 --- a/reflex/components/sonner/toast.py +++ b/reflex/components/sonner/toast.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, ClassVar, Literal, Optional, Union +from typing import Any, Literal, Optional, Union from reflex.base import Base from reflex.components.component import Component, ComponentNamespace @@ -17,6 +17,7 @@ from reflex.utils.serializers import serializer from reflex.vars import VarData from reflex.vars.base import LiteralVar, Var from reflex.vars.function import FunctionVar +from reflex.vars.number import ternary_operation from reflex.vars.object import ObjectVar LiteralPosition = Literal[ @@ -217,9 +218,6 @@ class Toaster(Component): # Pauses toast timers when the page is hidden, e.g., when the tab is backgrounded, the browser is minimized, or the OS is locked. pause_when_page_is_hidden: Var[bool] - # Marked True when any Toast component is created. - is_used: ClassVar[bool] = False - def add_hooks(self) -> list[Var | str]: """Add hooks for the toaster component. @@ -241,13 +239,17 @@ class Toaster(Component): @staticmethod def send_toast( - message: str | Var = "", level: str | None = None, **props + message: str | Var = "", + level: str | None = None, + fallback_to_alert: bool = False, + **props, ) -> EventSpec: """Send a toast message. Args: message: The message to display. level: The level of the toast. + fallback_to_alert: Whether to fallback to an alert if the toaster is not created. **props: The options for the toast. Raises: @@ -256,11 +258,6 @@ class Toaster(Component): Returns: The toast event. """ - if not Toaster.is_used: - raise ValueError( - "Toaster component must be created before sending a toast. (use `rx.toast.provider()`)" - ) - toast_command = ( ObjectVar.__getattr__(toast_ref.to(dict), level) if level else toast_ref ).to(FunctionVar) @@ -277,6 +274,21 @@ class Toaster(Component): else: toast = toast_command.call(message) + if fallback_to_alert: + toast = ternary_operation( + toast_ref.bool(), + toast, + FunctionVar("window.alert").call( + Var.create( + message + if isinstance(message, str) and message + else props.get("title", props.get("description", "")) + ) + .to(str) + .replace("
", "\n") + ), + ) + return run_script(toast) @staticmethod @@ -379,7 +391,6 @@ class Toaster(Component): Returns: The toaster component. """ - cls.is_used = True return super().create(*children, **props) diff --git a/reflex/components/sonner/toast.pyi b/reflex/components/sonner/toast.pyi index cb637bfff..220e5b6b0 100644 --- a/reflex/components/sonner/toast.pyi +++ b/reflex/components/sonner/toast.pyi @@ -3,7 +3,7 @@ # ------------------- DO NOT EDIT ---------------------- # This file was generated by `reflex/utils/pyi_generator.py`! # ------------------------------------------------------ -from typing import Any, ClassVar, Dict, Literal, Optional, Union, overload +from typing import Any, Dict, Literal, Optional, Union, overload from reflex.base import Base from reflex.components.component import Component, ComponentNamespace @@ -60,12 +60,13 @@ class ToastProps(PropsBase, NoExtrasAllowedProps): def dict(self, *args: Any, **kwargs: Any) -> dict[str, Any]: ... class Toaster(Component): - is_used: ClassVar[bool] = False - def add_hooks(self) -> list[Var | str]: ... @staticmethod def send_toast( - message: str | Var = "", level: str | None = None, **props + message: str | Var = "", + level: str | None = None, + fallback_to_alert: bool = False, + **props, ) -> EventSpec: ... @staticmethod def toast_info(message: str | Var = "", **kwargs: Any): ... @@ -185,13 +186,17 @@ class ToastNamespace(ComponentNamespace): @staticmethod def __call__( - message: Union[str, Var] = "", level: Optional[str] = None, **props + message: Union[str, Var] = "", + level: Optional[str] = None, + fallback_to_alert: bool = False, + **props, ) -> "EventSpec": """Send a toast message. Args: message: The message to display. level: The level of the toast. + fallback_to_alert: Whether to fallback to an alert if the toaster is not created. **props: The options for the toast. Raises: diff --git a/tests/units/test_state.py b/tests/units/test_state.py index e0390c5ac..fc4b5fe3b 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -35,7 +35,6 @@ import reflex.config from reflex import constants from reflex.app import App from reflex.base import Base -from reflex.components.sonner.toast import Toaster from reflex.constants import CompileVars, RouteVar, SocketEvent from reflex.event import Event, EventHandler from reflex.state import ( @@ -1613,29 +1612,20 @@ async def test_state_with_invalid_yield(capsys, mock_app): rx.event.Event(token="fake_token", name="invalid_handler") ): assert not update.delta - if Toaster.is_used: - assert update.events == rx.event.fix_events( - [ - rx.toast( - "An error occurred.", - description="TypeError: Your handler test_state_with_invalid_yield..StateWithInvalidYield.invalid_handler must only return/yield: None, Events or other EventHandlers referenced by their class (not using `self`).
See logs for details.", - level="error", - id="backend_error", - position="top-center", - style={"width": "500px"}, - ) - ], - token="", - ) - else: - assert update.events == rx.event.fix_events( - [ - rx.window_alert( - "An error occurred.\nContact the website administrator." - ) - ], - token="", - ) + assert update.events == rx.event.fix_events( + [ + rx.toast( + "An error occurred.", + level="error", + fallback_to_alert=True, + description="TypeError: Your handler test_state_with_invalid_yield..StateWithInvalidYield.invalid_handler must only return/yield: None, Events or other EventHandlers referenced by their class (not using `self`).
See logs for details.", + id="backend_error", + position="top-center", + style={"width": "500px"}, + ) + ], + token="", + ) captured = capsys.readouterr() assert "must only return/yield: None, Events or other EventHandlers" in captured.out