From ff1e31ffc30c56bbeed45a1dfd82ca06527bd843 Mon Sep 17 00:00:00 2001 From: ShaanGS <254507949+ShaanGS@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:59:56 +0530 Subject: [PATCH 1/2] test: expect BackendVarFormatError for the remaining backend var UI uses --- tests/units/components/test_component.py | 40 ++++++++++++---- tests/units/reflex_base/vars/test_base.py | 56 +++++++++++++++++++++++ tests/units/test_app.py | 40 ++++++++++++++++ tests/units/test_event.py | 18 ++++---- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/tests/units/components/test_component.py b/tests/units/components/test_component.py index c5ed10d49b9..3abce2468b1 100644 --- a/tests/units/components/test_component.py +++ b/tests/units/components/test_component.py @@ -789,12 +789,15 @@ def test_component_create_unallowed_types(children, test_component): def test_component_create_backend_var_child(test_component): - """A backend var child reports its type rather than formatting the field.""" + """A backend var child raises the backend var format error.""" class BackendChildState(BaseState): _secret: int = 42 - with pytest.raises(ChildrenTypeError, match=r"received child of type Field\."): + with pytest.raises( + BackendVarFormatError, + match=r"Backend var 'BackendChildState\._secret' exists only", + ): test_component.create(BackendChildState._secret) @@ -844,14 +847,35 @@ def test_component_create_event_child(make_child, type_name, test_component): ] -def test_component_create_backend_var_prop(): - """A backend var passed to a typed prop reports its repr, not a format error.""" +class BackendPropState(BaseState): + """A state with a backend var, for typed prop misuse tests.""" + + _size: str = "3" + + +@pytest.mark.parametrize( + "create", + [ + lambda f: rx.heading("x", size=f), + lambda f: rx.el.div(title=f), + lambda f: rx.link("x", href=f), + ], + ids=["heading_size", "div_title", "link_href"], +) +def test_component_create_backend_var_prop(create: Callable[[Any], Component]): + """A backend var passed to a typed prop raises the backend var format error. - class BackendPropState(BaseState): - _size: str = "3" + The error must not include the field's default value. - with pytest.raises(TypeError, match=r"got value Field\(default='3'"): - rx.heading("x", size=BackendPropState._size) # pyright: ignore[reportArgumentType] + Args: + create: Creates a component with the backend var as a prop. + """ + with pytest.raises( + BackendVarFormatError, + match=r"Backend var 'BackendPropState\._size' exists only", + ) as exc_info: + create(BackendPropState._size) + assert "default=" not in str(exc_info.value) class BackendSrcState(BaseState): diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index a121675e395..6a6488460cd 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -1623,6 +1623,62 @@ class Model(EvenMoreBasicBaseState): use(Model._secret) +@pytest.mark.parametrize( + "use", + [ + lambda f: f == "x", + lambda f: f != "x", + lambda f: f in ["x", "y"], + lambda f: bool(f), + ], + ids=["eq", "ne", "in_list", "bool"], +) +def test_backend_field_comparison_raises_format_error( + use: typing.Callable[[Any], Any], +): + """Comparing or truth-testing a backend var raises instead of a plain bool. + + Args: + use: Compares or truth-tests the backend field. + """ + + class Model(EvenMoreBasicBaseState): + _secret: str = "s" + + with pytest.raises( + BackendVarFormatError, match=r"Backend var 'Model\._secret' exists only" + ): + use(Model._secret) + + +def test_backend_field_identity_and_hash_unchanged(): + """Fields still compare and hash by identity with other fields.""" + + class Model(EvenMoreBasicBaseState): + _secret: str = "s" + _other: str = "o" + + secret, other = Model._secret, Model._other + assert secret == secret + assert secret != other + assert {secret: 1, other: 2}[secret] == 1 + assert secret in [other, secret] + assert len({secret, other, secret}) == 2 + + +def test_mixin_field_comparison_raises_format_error(): + """Comparing or truth-testing a mixin's field says to use the including state.""" + + class Mixin(EvenMoreBasicBaseState, mixin=True): + count: int = 0 + + match = r"Var 'Mixin\.count' is declared on a mixin state" + with pytest.raises(BackendVarFormatError, match=match): + Mixin.count == 1 # noqa: B015 + with pytest.raises(BackendVarFormatError, match=match): + bool(Mixin.count) + + def test_mixin_field_literal_var_raises_format_error(): """A mixin's field inside a LiteralVar says to use the including state.""" diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 121df59ad75..23cb7273122 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -333,6 +333,46 @@ def test_add_page_backend_var_meta_raises_format_error( app.add_page(index_page, route="/meta", **{key: BackendMetaState._meta}) # pyright: ignore[reportArgumentType] +def test_add_page_backend_var_meta_content_raises_format_error( + app: App, index_page: ComponentCallable +): + """A backend var in a meta tag's content raises when the page is compiled. + + Args: + app: The app to test. + index_page: The index page. + """ + app.add_page( + index_page, + route="/meta", + meta=[{"name": "x", "content": BackendMetaState._meta}], + ) + with pytest.raises( + exceptions.BackendVarFormatError, + match=r"Backend var 'BackendMetaState\._meta' exists only", + ): + app._compile_page("meta") + + +@pytest.mark.parametrize("as_list", [False, True]) +def test_add_page_backend_var_on_load_raises_format_error( + app: App, index_page: ComponentCallable, as_list: bool +): + """A backend var passed as on_load raises when the page is added. + + Args: + app: The app to test. + index_page: The index page. + as_list: Whether on_load is given as a list. + """ + on_load = [BackendMetaState._meta] if as_list else BackendMetaState._meta + with pytest.raises( + exceptions.BackendVarFormatError, + match=r"Backend var 'BackendMetaState\._meta' exists only", + ): + app.add_page(index_page, route="/meta", on_load=on_load) # pyright: ignore[reportArgumentType] + + def test_add_page_set_route(app: App, index_page: ComponentCallable): """Test adding a page to an app. diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 7d66a558d6e..d6d249a8d57 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -32,7 +32,6 @@ from reflex_base.utils.exceptions import ( BackendVarFormatError, EventHandlerArgTypeMismatchError, - EventHandlerTypeError, EventHandlerValueError, ) from reflex_base.utils.form import FormData @@ -266,20 +265,23 @@ def args_spec(value: Var[dict]) -> list[Var[dict]]: assert handler.prevent_default._type_hints is handler._type_hints -def test_backend_var_event_arg_reports_repr(): - """A backend var passed as an event arg reports its repr, not a format error.""" +def test_backend_var_event_arg_raises_format_error(): + """A backend var passed as an event arg raises the backend var format error.""" - class S(BaseState): + class BackendEventArgState(BaseState): _secret: int = 42 @event def on_event(self, value: int): pass - with pytest.raises(EventHandlerTypeError, match=r"Got Field\(default=42"): - S.on_event(S._secret) - with pytest.raises(EventHandlerTypeError, match=r"Got Field\(default=42"): - cast(EventSpec, S.on_event()).add_args(S._secret) # pyright: ignore[reportArgumentType] + match = r"Backend var 'BackendEventArgState\._secret' exists only" + with pytest.raises(BackendVarFormatError, match=match): + BackendEventArgState.on_event(BackendEventArgState._secret) + with pytest.raises(BackendVarFormatError, match=match): + cast(EventSpec, BackendEventArgState.on_event()).add_args( + BackendEventArgState._secret # pyright: ignore[reportArgumentType] + ) class BackendTriggerState(BaseState): From b042ce267927920953025ab56ffde936b8eda7be Mon Sep 17 00:00:00 2001 From: ShaanGS <254507949+ShaanGS@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:03:31 +0530 Subject: [PATCH 2/2] fix: raise BackendVarFormatError for the remaining backend var UI uses A backend var used as a component child raised a ChildrenTypeError that named neither the var nor a fix, and as a typed prop or an event handler argument a generic error that printed its default value. Comparing it (or a mixin's field) with a value or truth-testing it gave a plain False or True, so rx.cond on it always took one branch, and on_load accepted it. These now raise BackendVarFormatError. Fields still compare and hash by identity. Closes #7843. --- docs/changelog/upgrading/upgrading-to-0-10.md | 2 +- docs/changelog/upgrading/upgrading-to-0-11.md | 2 +- news/+backend-var-on-load.bugfix.md | 1 + .../news/+backend-var-ui-errors.bugfix.md | 1 + .../src/reflex_base/components/component.py | 6 +++ .../src/reflex_base/event/__init__.py | 16 +++++- .../reflex-base/src/reflex_base/vars/base.py | 50 +++++++++++++++++++ reflex/app.py | 10 ++-- 8 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 news/+backend-var-on-load.bugfix.md create mode 100644 packages/reflex-base/news/+backend-var-ui-errors.bugfix.md diff --git a/docs/changelog/upgrading/upgrading-to-0-10.md b/docs/changelog/upgrading/upgrading-to-0-10.md index d2aff4c1c6c..d95e968f1d0 100644 --- a/docs/changelog/upgrading/upgrading-to-0-10.md +++ b/docs/changelog/upgrading/upgrading-to-0-10.md @@ -9,7 +9,7 @@ This page covers the 0.10 changes most likely to break an existing app, or to be ## Reading a backend var on a state class -Reading a backend var on the state class, such as `State._items`, now returns its `Field` instead of the var's default value ([#7312](https://github.com/reflex-dev/reflex/pull/7312)). Passing it to a component raises `ChildrenTypeError` (as a child); a prop may raise `BackendVarFormatError` when its value is built or rendered (earlier 0.10 releases may instead raise `TypeError: Unsupported type for LiteralVar`), while typed props can still reject it with `TypeError` during component creation. Turning it into text with an f-string, `str.format()`, `str()` or `%` raises `BackendVarFormatError` (earlier 0.10 releases embed the `Field` text for `!s`, `str()` and `%`; `!r` and `repr()` still do). Reading the var on a state instance, such as `self._items`, is unchanged. +Reading a backend var on the state class, such as `State._items`, now returns its `Field` instead of the var's default value ([#7312](https://github.com/reflex-dev/reflex/pull/7312)). Passing it to a component as a child or a prop raises `BackendVarFormatError` (earlier releases may instead raise `ChildrenTypeError` for a child, `TypeError` for a typed prop, or `TypeError: Unsupported type for LiteralVar` when the value is built or rendered). Turning it into text with an f-string, `str.format()`, `str()` or `%` raises `BackendVarFormatError` (earlier 0.10 releases embed the `Field` text for `!s`, `str()` and `%`; `!r` and `repr()` still do). Reading the var on a state instance, such as `self._items`, is unchanged. Replace the class read with what the code meant: diff --git a/docs/changelog/upgrading/upgrading-to-0-11.md b/docs/changelog/upgrading/upgrading-to-0-11.md index 8bcf0ae96fc..75e16fa9749 100644 --- a/docs/changelog/upgrading/upgrading-to-0-11.md +++ b/docs/changelog/upgrading/upgrading-to-0-11.md @@ -241,7 +241,7 @@ def serialize_point(point: Point) -> dict: ## Printing a backend var on a state class -A backend var read on the state class, such as `State._secret`, is a `Field` since 0.10. In 0.11, `print()`, logging and `str()` of it raise `BackendVarFormatError`, as f-strings already did ([#7651](https://github.com/reflex-dev/reflex/pull/7651)). The same error replaces the `TypeError` or `ValueError` raised when the var is passed where a frontend value is needed, such as a component prop, `rx.toast(State._secret)`, or the `title`, `description` or `image` of `app.add_page(...)`. +A backend var read on the state class, such as `State._secret`, is a `Field` since 0.10. In 0.11, `print()`, logging and `str()` of it raise `BackendVarFormatError`, as f-strings already did ([#7651](https://github.com/reflex-dev/reflex/pull/7651)). The same error replaces the `TypeError` or `ValueError` raised when the var is passed where a frontend value is needed, such as a component child or prop, an event handler argument, `rx.toast(State._secret)`, or the `title`, `description`, `image` or `on_load` of `app.add_page(...)`. Comparing it with a value, as in `rx.cond(State._secret == "x", ...)`, or truth-testing it also raises, where it used to give a plain `False` or `True`. ```python class State(rx.State): diff --git a/news/+backend-var-on-load.bugfix.md b/news/+backend-var-on-load.bugfix.md new file mode 100644 index 00000000000..b0599fc859e --- /dev/null +++ b/news/+backend-var-on-load.bugfix.md @@ -0,0 +1 @@ +`app.add_page(...)` now raises `BackendVarFormatError` when `on_load` is a backend var such as `State._handler`, instead of accepting it. diff --git a/packages/reflex-base/news/+backend-var-ui-errors.bugfix.md b/packages/reflex-base/news/+backend-var-ui-errors.bugfix.md new file mode 100644 index 00000000000..04f5df626cc --- /dev/null +++ b/packages/reflex-base/news/+backend-var-ui-errors.bugfix.md @@ -0,0 +1 @@ +Using a backend var such as `State._secret` as a component child, a typed prop (`rx.el.div(title=State._secret)`, `rx.link(href=State._secret)`) or an event handler argument now raises `BackendVarFormatError`, which names the var and the fix. A child used to raise a `ChildrenTypeError` that named neither, and a typed prop or event argument a generic error that printed the var's default value. Comparing a backend var or a mixin's field with a value (`State._secret == "x"`) or truth-testing it now raises the same error, instead of a plain `False` or `True` that made `rx.cond` always take one branch. Fields still compare equal to themselves. diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 8cbc6993e53..cb0ab853a5a 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -947,6 +947,7 @@ def _post_init(self, *args, **kwargs): Raises: TypeError: If an invalid prop is passed. ValueError: If an event trigger passed is not valid. + BackendVarFormatError: If a child or typed prop is a field with no frontend var. """ # Set the id and children initially. children = kwargs.get("children", []) @@ -991,6 +992,8 @@ def _post_init(self, *args, **kwargs): # Check whether the key is a component prop. if is_var: + if isinstance(value, Field): + raise value._no_frontend_var_error() try: kwargs[key] = LiteralVar.create(value) @@ -1286,6 +1289,9 @@ def _validate_children(cls, children: tuple | list): if isinstance(child, (tuple, list)): cls._validate_children(child) + if isinstance(child, Field): + raise child._no_frontend_var_error() + # Make sure the child is a valid type. if isinstance(child, dict) or not isinstance(child, ComponentChildTypes): error = ChildrenTypeError(component=cls.__name__, child=child) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index ee6a856a856..0ebb1ce2c05 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -800,8 +800,12 @@ def __call__(self, *args: Any, **kwargs: Any) -> "EventSpec": Raises: EventHandlerTypeError: If the arguments are invalid. + BackendVarFormatError: If an argument is a field with no frontend var. """ - from reflex_base.utils.exceptions import EventHandlerTypeError + from reflex_base.utils.exceptions import ( + BackendVarFormatError, + EventHandlerTypeError, + ) if args and isinstance(args[0], self.state or _base_state_type()): # A decorator stacked above @rx.event calls the handler like the @@ -868,6 +872,8 @@ def __call__(self, *args: Any, **kwargs: Any) -> "EventSpec": # Otherwise, convert to JSON. try: payload.append((Var(_js_expr=fn_arg), LiteralVar.create(arg))) + except BackendVarFormatError: + raise except TypeError as e: msg = f"Arguments to event handlers must be Vars or JSON-serializable. Got {arg!r} of type {type(arg)}." raise EventHandlerTypeError(msg) from e @@ -1033,8 +1039,12 @@ def add_args(self, *args: Var) -> "EventSpec": Raises: EventHandlerTypeError: If the arguments are invalid. + BackendVarFormatError: If an argument is a field with no frontend var. """ - from reflex_base.utils.exceptions import EventHandlerTypeError + from reflex_base.utils.exceptions import ( + BackendVarFormatError, + EventHandlerTypeError, + ) n_self_args = 1 if self.handler.state is not None else 0 @@ -1048,6 +1058,8 @@ def add_args(self, *args: Var) -> "EventSpec": try: for arg in args: values.append(LiteralVar.create(value=arg)) # noqa: PERF401, RUF100 + except BackendVarFormatError: + raise except TypeError as e: msg = f"Arguments to event handlers must be Vars or JSON-serializable. Got {arg!r} of type {type(arg)}." raise EventHandlerTypeError(msg) from e diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 323f4ced6c1..fb70695d51a 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -4447,6 +4447,56 @@ def __str__(self) -> str: """ raise self._no_frontend_var_error() + def _has_no_frontend_var(self) -> bool: + """Whether class access reaches the field itself, not a Var. + + Returns: + Whether the field is a backend var or a mixin's field. + """ + return self._var is None and ( + self._backend or getattr(self._owner, "_mixin", False) + ) + + def __eq__(self, other: object) -> bool: + """Compare by identity, refusing to compare a backend var with a value. + + Class access reaches a backend var's field itself, so ``State._x == "a"`` + would otherwise be a plain ``False``, and ``rx.cond`` on it would always + take the same branch. Comparing two fields still works, by identity. + ``!=`` uses this method too. + + Args: + other: The object to compare with. + + Returns: + Whether the two are the same field. + + Raises: + BackendVarFormatError: If the field has no frontend var and the + other object is not a field. + """ + if not isinstance(other, Field): + if self._has_no_frontend_var(): + raise self._no_frontend_var_error() + return NotImplemented + return self is other + + # Defining __eq__ would otherwise make fields unhashable. + __hash__ = object.__hash__ + + def __bool__(self) -> bool: + """Refuse to truth-test a backend var, which would always be true. + + Returns: + True, for a field with a frontend var. + + Raises: + BackendVarFormatError: If the field has no frontend var. + """ + if self._has_no_frontend_var(): + raise self._no_frontend_var_error() + return True + def _no_frontend_var_error(self) -> BackendVarFormatError: """Build the error for using the field where a frontend var is needed. diff --git a/reflex/app.py b/reflex/app.py index 718b7e0c4ae..7364b2b6041 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -290,10 +290,10 @@ def default_error_boundary(*children: Component, **props) -> Component: def _reject_page_meta_fields(*values: Any) -> None: - """Reject page metadata given as a field with no frontend var. + """Reject page metadata or on_load events given as a field with no frontend var. Args: - *values: The page metadata values. + *values: The page metadata values or on_load events. Raises: BackendVarFormatError: If a value is a field with no frontend var. @@ -1436,7 +1436,7 @@ def add_page( context: Values passed to page for custom page-specific logic. Raises: - BackendVarFormatError: When title, description or image is a field with no frontend var. + BackendVarFormatError: When title, description, image or on_load is a field with no frontend var. PageValueError: When the component is not set for a non-404 page. RouteValueError: When the specified route name already exists. ValueError: When the route syntax is invalid. @@ -1485,11 +1485,15 @@ def _prepare_page( The validated page descriptor and its dynamic route arguments. Raises: + BackendVarFormatError: When title, description, image or on_load is a field with no frontend var. PageValueError: When the component is not set for a non-404 page. RouteValueError: When a route cannot be derived. ValueError: When the route syntax is invalid. """ _reject_page_meta_fields(title, description, image) + _reject_page_meta_fields( + *(on_load if is_non_string_sequence(on_load) else [on_load]) + ) route = self._page_route_key(component, route) if route is None: msg = "Route must be set if component is not a callable."