Skip to content

Commit aea955b

Browse files
committed
Merge remote-tracking branch 'origin/main' into masenf/exec-with-sys-executable
2 parents 7dfce2a + b459a7c commit aea955b

8 files changed

Lines changed: 409 additions & 249 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ dependencies = [
2424
"click >=8.2",
2525
"granian[reload] >=2.5.5",
2626
"httpx >=0.23.3,<1.0",
27-
"packaging >=24.2,<26",
27+
"packaging >=24.2,<27",
2828
"platformdirs >=4.3.7,<5.0",
2929
"psutil >=7.0.0,<8.0; sys_platform == 'win32'",
3030
"pydantic >=1.10.21,<3.0",

reflex/.templates/web/utils/state.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,16 @@ export const applyRestEvent = async (event, socket, navigate, params) => {
446446
return eventSent;
447447
};
448448

449+
/**
450+
* Resolve a socket reference to the actual socket object.
451+
* Handles both ref objects ({ current: Socket }) and raw sockets.
452+
* @param socket Either a ref object or raw socket.
453+
* @returns The actual socket object.
454+
*/
455+
const resolveSocket = (socket) => {
456+
return socket?.current ?? socket;
457+
};
458+
449459
/**
450460
* Queue events to be processed and trigger processing of queue.
451461
* @param events Array of events to queue.
@@ -471,7 +481,7 @@ export const queueEvents = async (
471481
];
472482
}
473483
event_queue.push(...events.filter((e) => e !== undefined && e !== null));
474-
await processEvent(socket.current, navigate, params);
484+
await processEvent(resolveSocket(socket), navigate, params);
475485
};
476486

477487
/**

reflex/event.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def substate_token(self) -> str:
8989
_EVENT_FIELDS: set[str] = {f.name for f in dataclasses.fields(Event)}
9090

9191
BACKGROUND_TASK_MARKER = "_reflex_background_task"
92+
EVENT_ACTIONS_MARKER = "_rx_event_actions"
9293

9394

9495
@dataclasses.dataclass(
@@ -2311,6 +2312,7 @@ class EventNamespace:
23112312

23122313
# Constants
23132314
BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER
2315+
EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER
23142316
_EVENT_FIELDS = _EVENT_FIELDS
23152317
FORM_DATA = FORM_DATA
23162318
upload_files = upload_files
@@ -2461,7 +2463,7 @@ def wrapper(
24612463
# Store decorator event actions on the function for later processing
24622464
event_actions = _build_event_actions()
24632465
if event_actions:
2464-
func._rx_event_actions = event_actions # pyright: ignore [reportFunctionMemberAccess]
2466+
setattr(func, EVENT_ACTIONS_MARKER, event_actions)
24652467
return func # pyright: ignore [reportReturnType]
24662468

24672469
if func is not None:

reflex/istate/proxy.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from reflex.state import BaseState, StateUpdate
2727

2828
T_STATE = TypeVar("T_STATE", bound="BaseState")
29+
T = TypeVar("T")
2930

3031

3132
class StateProxy(wrapt.ObjectProxy):
@@ -671,19 +672,23 @@ def __deepcopy__(self, memo: dict[int, Any] | None = None) -> Any:
671672
return copy.deepcopy(self.__wrapped__, memo=memo)
672673

673674
def __reduce_ex__(self, protocol_version: SupportsIndex):
674-
"""Get the state for redis serialization.
675+
"""Serialize the wrapped object for pickle, stripping off the proxy.
675676
676-
This method is called by cloudpickle to serialize the object.
677-
678-
It explicitly serializes the wrapped object, stripping off the mutable proxy.
677+
Returns a function that reconstructs to the wrapped object directly,
678+
ensuring pickle's memo system correctly tracks object identity.
679679
680680
Args:
681681
protocol_version: The protocol version.
682682
683683
Returns:
684-
Tuple of (wrapped class, empty args, class __getstate__)
684+
Tuple that reconstructs to the wrapped object.
685685
"""
686-
return self.__wrapped__.__reduce_ex__(protocol_version)
686+
return (_unwrap_for_pickle, (self.__wrapped__,))
687+
688+
689+
def _unwrap_for_pickle(obj: T) -> T:
690+
"""Return the object unchanged. Used by MutableProxy.__reduce_ex__."""
691+
return obj
687692

688693

689694
@serializer

reflex/state.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from reflex.environment import PerformanceMode, environment
4343
from reflex.event import (
4444
BACKGROUND_TASK_MARKER,
45+
EVENT_ACTIONS_MARKER,
4546
Event,
4647
EventHandler,
4748
EventSpec,
@@ -686,6 +687,9 @@ def _copy_fn(fn: Callable) -> Callable:
686687
newfn.__annotations__ = fn.__annotations__
687688
if mark := getattr(fn, BACKGROUND_TASK_MARKER, None):
688689
setattr(newfn, BACKGROUND_TASK_MARKER, mark)
690+
# Preserve event_actions from @rx.event decorator
691+
if event_actions := getattr(fn, EVENT_ACTIONS_MARKER, None):
692+
object.__setattr__(newfn, EVENT_ACTIONS_MARKER, event_actions)
689693
return newfn
690694

691695
@staticmethod
@@ -1163,7 +1167,7 @@ def _create_event_handler(
11631167
The event handler.
11641168
"""
11651169
# Check if function has stored event_actions from decorator
1166-
event_actions = getattr(fn, "_rx_event_actions", {})
1170+
event_actions = getattr(fn, EVENT_ACTIONS_MARKER, {})
11671171

11681172
return event_handler_cls(
11691173
fn=fn, state_full_name=cls.get_full_name(), event_actions=event_actions

tests/units/istate/test_proxy.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Tests for MutableProxy pickle behavior."""
2+
3+
import dataclasses
4+
import pickle
5+
6+
import reflex as rx
7+
from reflex.istate.proxy import MutableProxy
8+
9+
10+
@dataclasses.dataclass
11+
class Item:
12+
"""Simple picklable object for testing."""
13+
14+
id: int
15+
16+
17+
class ProxyTestState(rx.State):
18+
"""Test state with a list field."""
19+
20+
items: list[Item] = []
21+
22+
23+
def test_mutable_proxy_pickle_preserves_object_identity():
24+
"""Test that same object referenced directly and via proxy maintains identity."""
25+
state = ProxyTestState()
26+
obj = Item(1)
27+
28+
data = {
29+
"direct": [obj],
30+
"proxied": [MutableProxy(obj, state, "items")],
31+
}
32+
33+
unpickled = pickle.loads(pickle.dumps(data))
34+
35+
assert unpickled["direct"][0].id == 1
36+
assert unpickled["proxied"][0].id == 1
37+
assert unpickled["direct"][0] is unpickled["proxied"][0]

tests/units/test_state.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3737,6 +3737,21 @@ def test_bare_mixin_state() -> None:
37373737
assert ChildBareMixinState.get_root_state() == State
37383738

37393739

3740+
def test_mixin_event_handler_preserves_event_actions() -> None:
3741+
"""Test that event_actions from @rx.event decorator are preserved when inherited from mixins."""
3742+
3743+
class EventActionsMixin(BaseState, mixin=True):
3744+
@rx.event(prevent_default=True, stop_propagation=True)
3745+
def handle_with_actions(self):
3746+
pass
3747+
3748+
class UsesEventActionsMixin(EventActionsMixin, State):
3749+
pass
3750+
3751+
handler = UsesEventActionsMixin.handle_with_actions
3752+
assert handler.event_actions == {"preventDefault": True, "stopPropagation": True}
3753+
3754+
37403755
def test_assignment_to_undeclared_vars():
37413756
"""Test that an attribute error is thrown when undeclared vars are set."""
37423757

@@ -4453,9 +4468,5 @@ async def test_rebind_mutable_proxy(mock_app: rx.App, token: str) -> None:
44534468
) as state:
44544469
assert isinstance(state, MutableProxyState)
44554470
assert state.data["a"] == [2, 3]
4456-
if isinstance(mock_app.state_manager, StateManagerRedis):
4457-
# In redis mode, the object identity does not persist across async with self calls.
4458-
assert state.data["b"] == [2]
4459-
else:
4460-
# In disk/memory mode, the fact that data["b"] was mutated via data["a"] persists.
4461-
assert state.data["b"] == [2, 3]
4471+
# Object identity persists across serialization, so data["b"] is also mutated.
4472+
assert state.data["b"] == [2, 3]

0 commit comments

Comments
 (0)