@@ -2609,6 +2609,177 @@ async def test_background_task_no_chain():
26092609 await bts .bad_chain2 ()
26102610
26112611
2612+ class YieldFromBackgroundState (BaseState ):
2613+ """A state used to verify the type of `self` in a yielded event handler."""
2614+
2615+ counter : int = 0
2616+ follow_up_self_type : str = ""
2617+ follow_up_was_proxy : bool = True
2618+ dict_field : dict [str , int ] = {"a" : 1 }
2619+
2620+ @rx .event (background = True )
2621+ async def trigger (self ):
2622+ """A background handler that yields a non-background handler.
2623+
2624+ Yields:
2625+ A reference to the non-background follow_up handler.
2626+ """
2627+ # Sanity check: the background handler itself receives a StateProxy.
2628+ assert isinstance (self , StateProxy )
2629+ yield YieldFromBackgroundState .follow_up ()
2630+
2631+ @rx .event (background = True )
2632+ async def trigger_inside_lock (self ):
2633+ """A background handler that yields a non-background handler from inside `async with self`.
2634+
2635+ Yields:
2636+ A reference to the non-background follow_up handler.
2637+ """
2638+ assert isinstance (self , StateProxy )
2639+ async with self :
2640+ # Inside the lock, `self` is still a StateProxy (now mutable).
2641+ assert isinstance (self , StateProxy )
2642+ self .counter += 1
2643+ yield YieldFromBackgroundState .follow_up ()
2644+
2645+ @rx .event (background = True )
2646+ async def trigger_with_arg (self ):
2647+ """A background handler that yields a non-background handler with a state mutable as arg.
2648+
2649+ Yields:
2650+ A reference to the non-background follow_up_with_arg handler,
2651+ passing `self.dict_field` (a state-owned mutable) as the argument.
2652+ """
2653+ # Access the mutable through the StateProxy (returns ImmutableMutableProxy)
2654+ # and pass it as an argument to the yielded non-background handler.
2655+ yield YieldFromBackgroundState .follow_up_with_arg (self .dict_field )
2656+
2657+ @rx .event
2658+ def follow_up (self ):
2659+ """A non-background handler invoked via yield from a background handler.
2660+
2661+ Writes to state directly (no `async with self`); this only works if
2662+ `self` is the real state, not a StateProxy.
2663+ """
2664+ # Record what we observed *before* mutating, in case the write fails.
2665+ self .follow_up_was_proxy = isinstance (self , StateProxy )
2666+ self .follow_up_self_type = type (self ).__name__
2667+ # If `self` were a StateProxy outside an `async with self` block, this
2668+ # would raise ImmutableStateError.
2669+ self .counter += 1
2670+
2671+ @rx .event
2672+ def follow_up_with_arg (self , arg : dict [str , int ]):
2673+ """A non-background handler that mutates an argument passed to it.
2674+
2675+ Args:
2676+ arg: A dict argument that the handler will mutate.
2677+ """
2678+ # Mutating the arg should succeed: it must NOT be an
2679+ # ImmutableMutableProxy bound to the (now-immutable) trigger StateProxy.
2680+ arg ["b" ] = 2
2681+ # Persist a copy onto the (real) state so the test can verify what was seen.
2682+ self .dict_field = dict (arg )
2683+
2684+
2685+ @pytest .mark .asyncio
2686+ @pytest .mark .parametrize (
2687+ ("trigger_handler" , "expected_counter" ),
2688+ [
2689+ ("trigger" , 1 ),
2690+ ("trigger_inside_lock" , 2 ),
2691+ ],
2692+ )
2693+ async def test_yielded_non_background_event_receives_real_state (
2694+ mock_app : rx .App ,
2695+ token : str ,
2696+ mock_base_state_event_processor : BaseStateEventProcessor ,
2697+ state_manager : StateManager ,
2698+ trigger_handler : str ,
2699+ expected_counter : int ,
2700+ ):
2701+ """A non-background event yielded by a background event must run on the real state.
2702+
2703+ The yielded handler must NOT receive a StateProxy and must be able to
2704+ modify state directly without `async with self`. This holds whether the
2705+ yield happens outside or inside the background handler's `async with self`.
2706+
2707+ Args:
2708+ mock_app: An app that will be returned by `get_app()`.
2709+ token: A token.
2710+ mock_base_state_event_processor: The event processor.
2711+ state_manager: A state manager instance.
2712+ trigger_handler: The name of the background handler to invoke.
2713+ expected_counter: The expected counter value after both handlers run.
2714+ """
2715+ async with mock_base_state_event_processor as processor :
2716+ future = await processor .enqueue (
2717+ token ,
2718+ Event (
2719+ name = f"{ YieldFromBackgroundState .get_full_name ()} .{ trigger_handler } " ,
2720+ payload = {},
2721+ ),
2722+ )
2723+ # Wait for the trigger and its yielded follow_up to fully complete.
2724+ await future .wait_all ()
2725+
2726+ if environment .REFLEX_OPLOCK_ENABLED .get ():
2727+ await state_manager .close ()
2728+
2729+ state = await state_manager .get_state (
2730+ BaseStateToken (ident = token , cls = YieldFromBackgroundState )
2731+ )
2732+ assert isinstance (state , YieldFromBackgroundState )
2733+ # Direct mutation by the yielded handler succeeded and was persisted.
2734+ assert state .counter == expected_counter
2735+ # The yielded handler did not receive a StateProxy.
2736+ assert state .follow_up_was_proxy is False
2737+ assert state .follow_up_self_type == YieldFromBackgroundState .__name__
2738+
2739+
2740+ @pytest .mark .asyncio
2741+ async def test_yielded_event_arg_from_background_state_is_mutable (
2742+ mock_app : rx .App ,
2743+ token : str ,
2744+ mock_base_state_event_processor : BaseStateEventProcessor ,
2745+ state_manager : StateManager ,
2746+ ):
2747+ """A mutable arg passed by a background event must be mutable in the yielded handler.
2748+
2749+ Regression: when a background handler yields ``Handler(self.some_dict)``,
2750+ ``self.some_dict`` is an ``ImmutableMutableProxy`` tied to the trigger's
2751+ ``StateProxy``. Once the trigger releases the lock, that proxy refuses
2752+ writes -- so the yielded non-background handler can't mutate the arg it
2753+ was given. The arg must be unwrapped (or otherwise made mutable) before
2754+ being delivered to the yielded handler.
2755+
2756+ Args:
2757+ mock_app: An app that will be returned by `get_app()`.
2758+ token: A token.
2759+ mock_base_state_event_processor: The event processor.
2760+ state_manager: A state manager instance.
2761+ """
2762+ async with mock_base_state_event_processor as processor :
2763+ future = await processor .enqueue (
2764+ token ,
2765+ Event (
2766+ name = f"{ YieldFromBackgroundState .get_full_name ()} .trigger_with_arg" ,
2767+ payload = {},
2768+ ),
2769+ )
2770+ await future .wait_all ()
2771+
2772+ if environment .REFLEX_OPLOCK_ENABLED .get ():
2773+ await state_manager .close ()
2774+
2775+ state = await state_manager .get_state (
2776+ BaseStateToken (ident = token , cls = YieldFromBackgroundState )
2777+ )
2778+ assert isinstance (state , YieldFromBackgroundState )
2779+ # The yielded handler successfully mutated the dict it was passed.
2780+ assert state .dict_field == {"a" : 1 , "b" : 2 }
2781+
2782+
26122783def test_mutable_list (mutable_state : MutableTestState ):
26132784 """Test that mutable lists are tracked correctly.
26142785
0 commit comments