diff --git a/packages/reflex-base/news/6726.bugfix.md b/packages/reflex-base/news/6726.bugfix.md new file mode 100644 index 00000000000..543eb00db10 --- /dev/null +++ b/packages/reflex-base/news/6726.bugfix.md @@ -0,0 +1 @@ +Custom attributes set on a `Field` are now preserved (deep-copied) when the state metaclass rebuilds fields, instead of being silently discarded. The reserved `annotation` attribute is never carried over so rebuilt fields are not misidentified as pydantic fields. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index f7212bef821..1e5629e30d7 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -3386,6 +3386,11 @@ def dispatch( FIELD_TYPE = TypeVar("FIELD_TYPE") +# Custom attrs never copied from a source field: get_field_type duck-types +# pydantic fields on `.annotation`, so carrying it over would shadow the +# real class annotation. +_RESERVED_FIELD_ATTRS = frozenset({"annotation"}) + class Field(Generic[FIELD_TYPE]): """A field for a state.""" @@ -3402,6 +3407,7 @@ def __init__( is_var: bool = True, annotated_type: GenericType # pyright: ignore [reportRedeclaration] | _MISSING_TYPE = MISSING, + source_field: Field | None = None, ) -> None: """Initialize the field. @@ -3410,6 +3416,8 @@ def __init__( default_factory: The default factory for the field. is_var: Whether the field is a Var. annotated_type: The annotated type for the field. + source_field: If given, deep-copy custom (non-reserved) attributes + from this field that the new field did not compute itself. """ self.default = default self.default_factory = default_factory @@ -3440,6 +3448,10 @@ def __init__( self.type_ = self.type_origin = type_origin else: self.outer_type_ = self.annotated_type = self.type_ = self.type_origin = Any + if source_field is not None: + for key, value in source_field.__dict__.items(): + if key not in self.__dict__ and key not in _RESERVED_FIELD_ATTRS: + self.__dict__[key] = copy.deepcopy(value) def default_value(self) -> FIELD_TYPE: """Get the default value for the field. @@ -3686,12 +3698,14 @@ def __new__( default=value.default, is_var=value.is_var, annotated_type=figure_out_type(value.default), + source_field=value, ) else: new_value = Field( default_factory=value.default_factory, is_var=value.is_var, annotated_type=Any, + source_field=value, ) elif ( not key.startswith("__") @@ -3741,6 +3755,7 @@ def __new__( default_factory=value.default_factory, is_var=value.is_var, annotated_type=annotation, + source_field=value, ) own_fields[key] = value diff --git a/tests/units/reflex_base/vars/__init__.py b/tests/units/reflex_base/vars/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py new file mode 100644 index 00000000000..7776409511b --- /dev/null +++ b/tests/units/reflex_base/vars/test_base.py @@ -0,0 +1,80 @@ +"""Tests for reflex_base.vars.base state metaclass field handling.""" + +from typing import Any + +from reflex_base.utils.types import get_field_type +from reflex_base.vars.base import EvenMoreBasicBaseState, field + +_MARKER_ATTR = "_marker" + + +def test_custom_field_attr_survives_annotated_rebuild(): + """A custom attribute on an annotated Field survives a rebuild.""" + f = field("x") + setattr(f, _MARKER_ATTR, "tag") + + class MyState(EvenMoreBasicBaseState): + name: str = f # pyright: ignore[reportAssignmentType] + + rebuilt = MyState.get_fields()["name"] + assert getattr(rebuilt, _MARKER_ATTR, None) == "tag" + assert rebuilt.annotated_type is str + + +def test_custom_field_attr_survives_unannotated_rebuild(): + """A custom attribute survives an inferred-type Field rebuild.""" + f = field(0) + setattr(f, _MARKER_ATTR, "tag") + + class MyState(EvenMoreBasicBaseState): + count = f + + rebuilt = MyState.get_fields()["count"] + assert getattr(rebuilt, _MARKER_ATTR, None) == "tag" + assert rebuilt.annotated_type is int + + +def test_custom_field_attr_survives_unannotated_factory_rebuild(): + """A custom attribute survives a default-factory Field rebuild.""" + f = field(default_factory=list) + setattr(f, _MARKER_ATTR, "tag") + + class MyState(EvenMoreBasicBaseState): + items = f + + rebuilt = MyState.get_fields()["items"] + assert getattr(rebuilt, _MARKER_ATTR, None) == "tag" + assert rebuilt.annotated_type is Any + + +def test_reserved_annotation_attr_not_copied(): + """A custom `annotation` attr must not make the rebuilt Field look pydantic. + + get_field_type duck-types __fields__ entries on `.annotation`, so copying + it would shadow the real class annotation. + """ + f = field("x") + f.annotation = int # pyright: ignore[reportAttributeAccessIssue] + + class MyState(EvenMoreBasicBaseState): + name: str = f # pyright: ignore[reportAssignmentType] + + rebuilt = MyState.get_fields()["name"] + assert "annotation" not in rebuilt.__dict__ + assert get_field_type(MyState, "name") is str + + +def test_custom_mutable_attr_is_deepcopied(): + """Mutable custom attrs are deep-copied, not shared by reference.""" + f = field("x") + opts = {"a": [1]} + f._opts = opts # pyright: ignore[reportAttributeAccessIssue] + + class MyState(EvenMoreBasicBaseState): + name: str = f # pyright: ignore[reportAssignmentType] + + rebuilt = MyState.get_fields()["name"] + assert rebuilt._opts == {"a": [1]} # pyright: ignore[reportAttributeAccessIssue] + assert rebuilt._opts is not opts # pyright: ignore[reportAttributeAccessIssue] + opts["a"].append(2) + assert rebuilt._opts == {"a": [1]} # pyright: ignore[reportAttributeAccessIssue]