diff --git a/news/6725.bugfix.md b/news/6725.bugfix.md new file mode 100644 index 00000000000..68441f4ae38 --- /dev/null +++ b/news/6725.bugfix.md @@ -0,0 +1 @@ +Event handlers and computed vars inherited from a state mixin now preserve the source function's custom attributes and keyword-only defaults. diff --git a/reflex/state.py b/reflex/state.py index 3762ad0ac2a..a488cab97e7 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -31,7 +31,6 @@ from reflex_base.constants.state import FIELD_MARKER from reflex_base.environment import PerformanceMode, environment from reflex_base.event import ( - BACKGROUND_TASK_MARKER, EVENT_ACTIONS_MARKER, Event, EventHandler, @@ -755,11 +754,8 @@ def _copy_fn(fn: Callable) -> Callable: closure=fn.__closure__, ) newfn.__annotations__ = fn.__annotations__ - if mark := getattr(fn, BACKGROUND_TASK_MARKER, None): - setattr(newfn, BACKGROUND_TASK_MARKER, mark) - # Preserve event_actions from @rx.event decorator - if event_actions := getattr(fn, EVENT_ACTIONS_MARKER, None): - object.__setattr__(newfn, EVENT_ACTIONS_MARKER, event_actions) + newfn.__kwdefaults__ = fn.__kwdefaults__ + newfn.__dict__.update(fn.__dict__) return newfn @staticmethod diff --git a/tests/units/test_state.py b/tests/units/test_state.py index b0aaf6e022f..0aea285babd 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -4243,6 +4243,73 @@ def test_bare_mixin_state() -> None: assert ChildBareMixinState.get_root_state() == State +class MarkerMixin(State, mixin=True): + """A mixin state with a getter and handler carrying custom function attributes.""" + + @rx.var + def marked_computed(self) -> str: + """A computed var whose getter is tagged with a custom attribute. + + Returns: + A static string. + """ + return "marked" + + marked_computed.fget._custom_marker = object() # pyright: ignore [reportFunctionMemberAccess] + + def marked_handler(self): + """An event handler tagged with a custom attribute.""" + + marked_handler._custom_marker = object() # pyright: ignore [reportFunctionMemberAccess] + + def kwonly_default_handler(self, *, count: int = 1) -> int: + """An event handler with a keyword-only default argument. + + Args: + count: A keyword-only argument with a default. + + Returns: + The count. + """ + return count + + +class UsesMarkerMixin(MarkerMixin, State): + """A state that pulls in the marked mixin getter/handler.""" + + +def test_copy_fn_preserves_custom_function_attributes() -> None: + """Test that _copy_fn preserves arbitrary attributes set on mixin functions.""" + orig_computed_fget = MarkerMixin.__dict__["marked_computed"].fget + copied_computed_fget = UsesMarkerMixin.computed_vars["marked_computed"].fget + assert copied_computed_fget is not orig_computed_fget + assert ( + copied_computed_fget._custom_marker # pyright: ignore [reportFunctionMemberAccess] + is orig_computed_fget._custom_marker # pyright: ignore [reportFunctionMemberAccess] + ) + + orig_handler_fn = MarkerMixin.__dict__["marked_handler"] + copied_handler_fn = UsesMarkerMixin.event_handlers["marked_handler"].fn + assert copied_handler_fn is not orig_handler_fn + assert ( + copied_handler_fn._custom_marker # pyright: ignore [reportFunctionMemberAccess] + is orig_handler_fn._custom_marker # pyright: ignore [reportFunctionMemberAccess] + ) + + # The copy's __dict__ is independent of the source function's __dict__. + assert copied_handler_fn.__dict__ is not orig_handler_fn.__dict__ + copied_handler_fn.__dict__["_leaked"] = True + assert "_leaked" not in orig_handler_fn.__dict__ + + +def test_copy_fn_preserves_kwonly_defaults() -> None: + """Test that _copy_fn preserves keyword-only default arguments.""" + handler_fn = UsesMarkerMixin.event_handlers["kwonly_default_handler"].fn + assert handler_fn.__kwdefaults__ == {"count": 1} + instance = UsesMarkerMixin() + assert handler_fn(instance) == 1 + + def test_mixin_event_handler_preserves_event_actions() -> None: """Test that event_actions from @rx.event decorator are preserved when inherited from mixins.""" @@ -4258,6 +4325,21 @@ class UsesEventActionsMixin(EventActionsMixin, State): assert handler.event_actions == {"preventDefault": True, "stopPropagation": True} +def test_mixin_event_handler_preserves_background_task_marker() -> None: + """Test that the background task marker is preserved when inherited from mixins.""" + + class BackgroundTaskMixin(BaseState, mixin=True): + @rx.event(background=True) + async def handle_in_background(self): + pass + + class UsesBackgroundTaskMixin(BackgroundTaskMixin, State): + pass + + handler = UsesBackgroundTaskMixin.handle_in_background + assert handler.is_background # pyright: ignore [reportAttributeAccessIssue] + + def test_assignment_to_undeclared_vars(): """Test that an attribute error is thrown when undeclared vars are set."""