simplify toast banner logic (#4853)

* simplify toast banner logic

* expose toast

* default back to title and desc, and replace brs with new lines
This commit is contained in:
Khaleel Al-Adhami 2025-02-20 15:10:15 -08:00 committed by GitHub
parent 836e8f8ce9
commit 8943341605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 80 additions and 131 deletions

View File

@ -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="<br/>".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="<br/>".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)

View File

@ -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)

View File

@ -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.

View File

@ -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("<br/>", "\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)

View File

@ -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:

View File

@ -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.<locals>.StateWithInvalidYield.invalid_handler must only return/yield: None, Events or other EventHandlers referenced by their class (not using `self`).<br/>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.<locals>.StateWithInvalidYield.invalid_handler must only return/yield: None, Events or other EventHandlers referenced by their class (not using `self`).<br/>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