From 3f368d27a916693f6456c63d33ceeb9d2de14793 Mon Sep 17 00:00:00 2001 From: Farhan Date: Thu, 9 Jul 2026 18:25:39 +0500 Subject: [PATCH 1/2] fix(vars): preserve custom Field attributes in state metaclass BaseStateMeta rebuilt Field instances for state annotations, discarding any custom attributes callers had set. Copy over attrs the rebuilt field did not recompute via Field._copy_custom_attrs_from so subclassed or customized fields keep their extra state. --- .../reflex-base/src/reflex_base/vars/base.py | 19 ++++++-- tests/units/reflex_base/vars/__init__.py | 0 tests/units/reflex_base/vars/test_base.py | 43 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 tests/units/reflex_base/vars/__init__.py create mode 100644 tests/units/reflex_base/vars/test_base.py diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index f7212bef821..339eaeafc01 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -3441,6 +3441,19 @@ def __init__( else: self.outer_type_ = self.annotated_type = self.type_ = self.type_origin = Any + def _copy_custom_attrs_from(self, source: Field) -> Self: + """Copy source attrs that this field did not recompute. + + Args: + source: The field to copy custom attributes from. + + Returns: + This field. + """ + for key, value in source.__dict__.items(): + self.__dict__.setdefault(key, value) + return self + def default_value(self) -> FIELD_TYPE: """Get the default value for the field. @@ -3686,13 +3699,13 @@ def __new__( default=value.default, is_var=value.is_var, annotated_type=figure_out_type(value.default), - ) + )._copy_custom_attrs_from(value) else: new_value = Field( default_factory=value.default_factory, is_var=value.is_var, annotated_type=Any, - ) + )._copy_custom_attrs_from(value) elif ( not key.startswith("__") and not callable(value) @@ -3741,7 +3754,7 @@ def __new__( default_factory=value.default_factory, is_var=value.is_var, annotated_type=annotation, - ) + )._copy_custom_attrs_from(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..cb443850643 --- /dev/null +++ b/tests/units/reflex_base/vars/test_base.py @@ -0,0 +1,43 @@ +"""Tests for reflex_base.vars.base state metaclass field handling.""" + +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" From c5774900c4eeba7e18d6ab533463a27d1102710c Mon Sep 17 00:00:00 2001 From: Farhan Date: Fri, 10 Jul 2026 20:40:15 +0500 Subject: [PATCH 2/2] fix(vars): deep-copy custom Field attrs and skip reserved annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold custom-attribute copying into Field.__init__ via a source_field parameter instead of the post-hoc _copy_custom_attrs_from helper. Copied attrs are now deep-copied so mutable values aren't shared between the original and rebuilt fields, and the reserved `annotation` attribute is never carried over — get_field_type duck-types pydantic fields on `.annotation`, so copying it would shadow the real class annotation. --- packages/reflex-base/news/6726.bugfix.md | 1 + .../reflex-base/src/reflex_base/vars/base.py | 34 +++++++++-------- tests/units/reflex_base/vars/test_base.py | 37 +++++++++++++++++++ 3 files changed, 56 insertions(+), 16 deletions(-) create mode 100644 packages/reflex-base/news/6726.bugfix.md 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 339eaeafc01..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,19 +3448,10 @@ def __init__( self.type_ = self.type_origin = type_origin else: self.outer_type_ = self.annotated_type = self.type_ = self.type_origin = Any - - def _copy_custom_attrs_from(self, source: Field) -> Self: - """Copy source attrs that this field did not recompute. - - Args: - source: The field to copy custom attributes from. - - Returns: - This field. - """ - for key, value in source.__dict__.items(): - self.__dict__.setdefault(key, value) - return self + 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. @@ -3699,13 +3698,15 @@ def __new__( default=value.default, is_var=value.is_var, annotated_type=figure_out_type(value.default), - )._copy_custom_attrs_from(value) + source_field=value, + ) else: new_value = Field( default_factory=value.default_factory, is_var=value.is_var, annotated_type=Any, - )._copy_custom_attrs_from(value) + source_field=value, + ) elif ( not key.startswith("__") and not callable(value) @@ -3754,7 +3755,8 @@ def __new__( default_factory=value.default_factory, is_var=value.is_var, annotated_type=annotation, - )._copy_custom_attrs_from(value) + source_field=value, + ) own_fields[key] = value diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index cb443850643..7776409511b 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -1,5 +1,8 @@ """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" @@ -41,3 +44,37 @@ class MyState(EvenMoreBasicBaseState): 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]