From 148960ea17efff201cd7af80b26752512fcd90c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:57:35 +0000 Subject: [PATCH 1/5] perf: stop deep-copying proxy-free event chaining payloads Event.from_event_type deep-copied every mutable payload value passed to a chained handler (yield OtherState.handler(rows)), even values that were never attached to any state. The copy only exists to detach state-bound MutableProxy instances (which can raise ImmutableStateError after a background task's lock is released, or silently alias live state data). Replace the unconditional deepcopy with a copy-on-write detach pass: plain list/tuple/dict subtrees without proxies pass through by reference, proxied subtrees are deep-copied off the proxy (as before), and opaque mutable objects keep the deepcopy fallback since they may hold proxies internally. reflex.istate.proxy registers MutableProxy with reflex_base at import time to keep the dependency direction intact. Detaching a 10k-row plain payload drops from ~35ms to ~7ms (4.9x); payloads that genuinely reference state data are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx --- .../src/reflex_base/event/__init__.py | 102 ++++++++++++++++-- reflex/istate/proxy.py | 7 +- tests/units/test_event.py | 82 ++++++++++++++ 3 files changed, 181 insertions(+), 10 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index ff94adde754..6befd8850df 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -144,16 +144,11 @@ def from_event_type( msg = f"Unexpected event type, {type(e)}." raise ValueError(msg) name = format.format_event_handler(e.handler) - # Deepcopy mutable values to detach them from any state-bound - # proxies (e.g. ImmutableMutableProxy from a background task's - # StateProxy). + # Detach mutable values from any state-bound proxies (e.g. + # ImmutableMutableProxy from a background task's StateProxy), + # copying only subtrees that are actually proxied. payload = { - k._js_expr: ( - decoded - if isinstance(decoded := v._decode(), _IMMUTABLE_PAYLOAD_TYPES) - else copy.deepcopy(decoded) - ) - for k, v in e.args + k._js_expr: _detach_state_proxies(v._decode(), {}) for k, v in e.args } # Create an event and append it to the list. @@ -184,6 +179,95 @@ def from_event_type( type(None), ) +# Proxy types that bind a value to a state instance. Populated by higher +# layers (reflex.istate.proxy) at import time; this package cannot import +# them directly without inverting the dependency direction. +_STATE_PROXY_TYPES: tuple[type, ...] = () + + +def _register_state_proxy_type(proxy_type: type) -> None: + """Register a proxy type whose payload values must be detached by copy. + + Args: + proxy_type: The state-bound proxy type (e.g. MutableProxy). + """ + global _STATE_PROXY_TYPES + if not issubclass(proxy_type, _STATE_PROXY_TYPES): + _STATE_PROXY_TYPES = (*_STATE_PROXY_TYPES, proxy_type) + + +# Exact-type fast path for the scalar scan below; isinstance against the +# _IMMUTABLE_PAYLOAD_TYPES tuple is ~5x slower per element. +_SCALAR_PAYLOAD_TYPES = frozenset(_IMMUTABLE_PAYLOAD_TYPES) + + +def _detach_state_proxies(value: Any, memo: dict[int, Any]) -> Any: + """Detach state-bound proxies from an event payload value. + + Subtrees containing no proxies are passed through by reference; each + proxied subtree is deep-copied off its proxy. Mutable values that are not + plain containers may hold proxies internally and are deep-copied whole, + as before. + + Args: + value: The decoded payload value to detach. + memo: Deepcopy memo shared across one payload argument, preserving + aliasing between the copied subtrees of that argument. + + Returns: + The value with every state-bound proxy replaced by a detached copy. + """ + cls = type(value) + if cls in _SCALAR_PAYLOAD_TYPES: + return value + # A state proxy's exact type is never a plain container type. + if cls is list or cls is tuple: + copied = None + for index, item in enumerate(value): + detached = ( + item + if type(item) in _SCALAR_PAYLOAD_TYPES + else _detach_state_proxies(item, memo) + ) + if copied is None: + if detached is item: + continue + copied = list(value[:index]) + copied.append(detached) + if copied is None: + return value + return cls(copied) if cls is tuple else copied + if cls is dict: + replacements = None + for key, item in value.items(): + if type(item) in _SCALAR_PAYLOAD_TYPES: + continue + detached = _detach_state_proxies(item, memo) + if detached is not item: + if replacements is None: + replacements = {} + replacements[key] = detached + if replacements is None: + return value + copied_dict = dict(value) + copied_dict.update(replacements) + return copied_dict + if isinstance(value, _STATE_PROXY_TYPES): + # The proxy's __deepcopy__ unwraps it and snapshots the wrapped data. + return copy.deepcopy(value, memo) + if isinstance(value, _IMMUTABLE_PAYLOAD_TYPES): + # Subclasses of the immutable scalar types. + return value + if cls is set: + # Set members are hashable, so they cannot be mutable-container + # proxies; only opaque hashable objects need the copy fallback. + if all(type(item) in _SCALAR_PAYLOAD_TYPES for item in value): + return value + return copy.deepcopy(value, memo) + # Opaque mutable object: may reference proxies internally, snapshot it. + return copy.deepcopy(value, memo) + + BACKGROUND_TASK_MARKER = "_reflex_background_task" EVENT_ACTIONS_MARKER = "_rx_event_actions" UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 776a519fe2c..7dfcd581790 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, SupportsIndex, TypeVar import wrapt -from reflex_base.event import Event +from reflex_base.event import Event, _register_state_proxy_type from reflex_base.event.context import EventContext from reflex_base.utils.exceptions import ImmutableStateError from reflex_base.utils.serializers import can_serialize, serialize, serializer @@ -704,6 +704,11 @@ def _unwrap_for_pickle(obj: T) -> T: return obj +# Event payloads containing MutableProxy values (or its subclasses) must be +# detached from the state by copying; anything else passes by reference. +_register_state_proxy_type(MutableProxy) + + @serializer def serialize_mutable_proxy(mp: MutableProxy): """Return the wrapped value of a MutableProxy. diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 72cc9a7da29..916cf728516 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -173,6 +173,88 @@ def fn_with_args(arg1, arg2): assert event.payload == {"arg1": arg1, "arg2": arg2} +class _ProxyPayloadState(BaseState): + rows: list[dict[str, int]] = [{"a": 1}] + + +def _payload_for(value: Any) -> Any: + """Build a fixed event passing value as the single handler arg. + + Args: + value: The value to pass to the handler. + + Returns: + The processed payload value delivered to the handler. + """ + + def fn_with_arg(arg): + pass + + fn_with_arg.__qualname__ = "fn_with_arg" + event = fix_events([EventHandler(fn=fn_with_arg)(value)])[0] + return event.payload["arg"] + + +def test_fix_events_shares_plain_payload_values(): + """Payload values without state-bound proxies pass by reference.""" + rows = [{"a": 1}, {"b": 2}] + assert _payload_for(rows) is rows + mapping = {"x": [1, 2], "y": (3, 4)} + assert _payload_for(mapping) is mapping + + +def test_fix_events_detaches_proxied_payload_values(): + """A MutableProxy payload value is detached from the state by copy.""" + from reflex.istate.proxy import MutableProxy + + state = _ProxyPayloadState() + proxied = state.rows + assert isinstance(proxied, MutableProxy) + + detached = _payload_for(proxied) + assert not isinstance(detached, MutableProxy) + assert detached == [{"a": 1}] + # Mutating the payload must not touch (or dirty) the state. + detached[0]["a"] = 42 + assert state.rows == [{"a": 1}] + assert "rows" not in state.dirty_vars + + +def test_fix_events_detaches_nested_proxied_payload_values(): + """Proxies nested in plain containers are detached; clean parts shared.""" + from reflex.istate.proxy import MutableProxy + + state = _ProxyPayloadState() + plain = [{"z": 9}] + # Iterating a proxied list wraps each mutable element in a proxy. + listed_rows = list(state.rows) + assert any(isinstance(item, MutableProxy) for item in listed_rows) + + value = {"wrapped": listed_rows, "plain": plain} + detached = _payload_for(value) + assert detached is not value + assert detached["plain"] is plain + assert not any(isinstance(item, MutableProxy) for item in detached["wrapped"]) + detached["wrapped"][0]["a"] = 42 + assert state.rows == [{"a": 1}] + + +def test_fix_events_copies_opaque_payload_objects(): + """Non-container mutable objects are still snapshotted by deepcopy.""" + import dataclasses as dc + + @dc.dataclass + class Opaque: + items: list[int] + + obj = Opaque(items=[1]) + detached = _payload_for([obj]) + assert detached[0] is not obj + assert detached[0].items == [1] + detached[0].items.append(2) + assert obj.items == [1] + + @pytest.mark.parametrize( ("input", "output"), [ From 7554e61ba5a06fd74b175397c0968c9463f5b3ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:58:10 +0000 Subject: [PATCH 2/5] chore: add news fragments for #6739 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx --- news/6739.performance.md | 1 + packages/reflex-base/news/6739.performance.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 news/6739.performance.md create mode 100644 packages/reflex-base/news/6739.performance.md diff --git a/news/6739.performance.md b/news/6739.performance.md new file mode 100644 index 00000000000..dbc358c13cb --- /dev/null +++ b/news/6739.performance.md @@ -0,0 +1 @@ +Event chaining (`yield OtherState.handler(rows)`) no longer deep-copies payload values that are not attached to any state: only state-bound `MutableProxy` subtrees are copied, making proxy-free payloads ~5x faster to chain. diff --git a/packages/reflex-base/news/6739.performance.md b/packages/reflex-base/news/6739.performance.md new file mode 100644 index 00000000000..dbc358c13cb --- /dev/null +++ b/packages/reflex-base/news/6739.performance.md @@ -0,0 +1 @@ +Event chaining (`yield OtherState.handler(rows)`) no longer deep-copies payload values that are not attached to any state: only state-bound `MutableProxy` subtrees are copied, making proxy-free payloads ~5x faster to chain. From ce210628525586353e5eae1dc7905caa7079c49f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:05:41 +0000 Subject: [PATCH 3/5] fix: drop cross-package proxy registration, handle cyclic payloads The proxy-type registration broke reflex against released reflex_base versions (ModuleNotFoundError on import). It was also unnecessary: a MutableProxy's exact type is never a plain container type, so proxies always take the opaque-object deepcopy fallback, which detaches them via __deepcopy__. Also guard the copy-on-write scan against self-referential containers (falling back to plain deepcopy, which preserves cycles), and point the new payload tests at Event.from_event_type instead of the deprecated fix_events shim, which builds payloads separately. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx --- .../src/reflex_base/event/__init__.py | 116 +++++++++++------- reflex/istate/proxy.py | 7 +- tests/units/test_event.py | 31 ++++- 3 files changed, 95 insertions(+), 59 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 6befd8850df..c33778ff67d 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -179,82 +179,103 @@ def from_event_type( type(None), ) -# Proxy types that bind a value to a state instance. Populated by higher -# layers (reflex.istate.proxy) at import time; this package cannot import -# them directly without inverting the dependency direction. -_STATE_PROXY_TYPES: tuple[type, ...] = () - - -def _register_state_proxy_type(proxy_type: type) -> None: - """Register a proxy type whose payload values must be detached by copy. - - Args: - proxy_type: The state-bound proxy type (e.g. MutableProxy). - """ - global _STATE_PROXY_TYPES - if not issubclass(proxy_type, _STATE_PROXY_TYPES): - _STATE_PROXY_TYPES = (*_STATE_PROXY_TYPES, proxy_type) - - # Exact-type fast path for the scalar scan below; isinstance against the # _IMMUTABLE_PAYLOAD_TYPES tuple is ~5x slower per element. _SCALAR_PAYLOAD_TYPES = frozenset(_IMMUTABLE_PAYLOAD_TYPES) -def _detach_state_proxies(value: Any, memo: dict[int, Any]) -> Any: +class _PayloadCycleError(Exception): + """Internal: a payload container references itself.""" + + +def _detach_state_proxies(value: Any) -> Any: """Detach state-bound proxies from an event payload value. - Subtrees containing no proxies are passed through by reference; each - proxied subtree is deep-copied off its proxy. Mutable values that are not - plain containers may hold proxies internally and are deep-copied whole, - as before. + Subtrees containing no proxies are passed through by reference. Mutable + values that are not plain containers are deep-copied whole, as before: + this covers state-bound MutableProxy values (whose exact type is never a + plain container type, and whose ``__deepcopy__`` unwraps the proxy and + snapshots the wrapped data) as well as opaque objects that may reference + proxies internally. Args: value: The decoded payload value to detach. + + Returns: + The value with every state-bound proxy replaced by a detached copy. + """ + try: + return _scan_detach(value, {}, set()) + except _PayloadCycleError: + # Self-referential containers: deepcopy preserves cycles via its memo. + return copy.deepcopy(value) + + +def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any: + """Recursive copy-on-write scan behind ``_detach_state_proxies``. + + Args: + value: The payload value (or subtree) to detach. memo: Deepcopy memo shared across one payload argument, preserving aliasing between the copied subtrees of that argument. + active: ids of the containers on the current descent path, used to + detect self-referential payloads. Returns: The value with every state-bound proxy replaced by a detached copy. + + Raises: + _PayloadCycleError: If a container references itself. """ cls = type(value) if cls in _SCALAR_PAYLOAD_TYPES: return value # A state proxy's exact type is never a plain container type. if cls is list or cls is tuple: - copied = None - for index, item in enumerate(value): - detached = ( - item - if type(item) in _SCALAR_PAYLOAD_TYPES - else _detach_state_proxies(item, memo) - ) - if copied is None: - if detached is item: - continue - copied = list(value[:index]) - copied.append(detached) + value_id = id(value) + if value_id in active: + raise _PayloadCycleError + active.add(value_id) + try: + copied = None + for index, item in enumerate(value): + detached = ( + item + if type(item) in _SCALAR_PAYLOAD_TYPES + else _scan_detach(item, memo, active) + ) + if copied is None: + if detached is item: + continue + copied = list(value[:index]) + copied.append(detached) + finally: + active.discard(value_id) if copied is None: return value return cls(copied) if cls is tuple else copied if cls is dict: - replacements = None - for key, item in value.items(): - if type(item) in _SCALAR_PAYLOAD_TYPES: - continue - detached = _detach_state_proxies(item, memo) - if detached is not item: - if replacements is None: - replacements = {} - replacements[key] = detached + value_id = id(value) + if value_id in active: + raise _PayloadCycleError + active.add(value_id) + try: + replacements = None + for key, item in value.items(): + if type(item) in _SCALAR_PAYLOAD_TYPES: + continue + detached = _scan_detach(item, memo, active) + if detached is not item: + if replacements is None: + replacements = {} + replacements[key] = detached + finally: + active.discard(value_id) if replacements is None: return value copied_dict = dict(value) copied_dict.update(replacements) return copied_dict - if isinstance(value, _STATE_PROXY_TYPES): - # The proxy's __deepcopy__ unwraps it and snapshots the wrapped data. - return copy.deepcopy(value, memo) if isinstance(value, _IMMUTABLE_PAYLOAD_TYPES): # Subclasses of the immutable scalar types. return value @@ -264,7 +285,8 @@ def _detach_state_proxies(value: Any, memo: dict[int, Any]) -> Any: if all(type(item) in _SCALAR_PAYLOAD_TYPES for item in value): return value return copy.deepcopy(value, memo) - # Opaque mutable object: may reference proxies internally, snapshot it. + # State proxy or other opaque mutable object: snapshot it. A proxy's + # __deepcopy__ unwraps it, detaching the copy from the state. return copy.deepcopy(value, memo) diff --git a/reflex/istate/proxy.py b/reflex/istate/proxy.py index 7dfcd581790..776a519fe2c 100644 --- a/reflex/istate/proxy.py +++ b/reflex/istate/proxy.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Any, SupportsIndex, TypeVar import wrapt -from reflex_base.event import Event, _register_state_proxy_type +from reflex_base.event import Event from reflex_base.event.context import EventContext from reflex_base.utils.exceptions import ImmutableStateError from reflex_base.utils.serializers import can_serialize, serialize, serializer @@ -704,11 +704,6 @@ def _unwrap_for_pickle(obj: T) -> T: return obj -# Event payloads containing MutableProxy values (or its subclasses) must be -# detached from the state by copying; anything else passes by reference. -_register_state_proxy_type(MutableProxy) - - @serializer def serialize_mutable_proxy(mp: MutableProxy): """Return the wrapped value of a MutableProxy. diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 916cf728516..156b964db5f 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -178,7 +178,7 @@ class _ProxyPayloadState(BaseState): def _payload_for(value: Any) -> Any: - """Build a fixed event passing value as the single handler arg. + """Build an event passing value as the single handler arg. Args: value: The value to pass to the handler. @@ -191,11 +191,11 @@ def fn_with_arg(arg): pass fn_with_arg.__qualname__ = "fn_with_arg" - event = fix_events([EventHandler(fn=fn_with_arg)(value)])[0] + event = Event.from_event_type([EventHandler(fn=fn_with_arg)(value)])[0] return event.payload["arg"] -def test_fix_events_shares_plain_payload_values(): +def test_from_event_type_shares_plain_payload_values(): """Payload values without state-bound proxies pass by reference.""" rows = [{"a": 1}, {"b": 2}] assert _payload_for(rows) is rows @@ -203,7 +203,7 @@ def test_fix_events_shares_plain_payload_values(): assert _payload_for(mapping) is mapping -def test_fix_events_detaches_proxied_payload_values(): +def test_from_event_type_detaches_proxied_payload_values(): """A MutableProxy payload value is detached from the state by copy.""" from reflex.istate.proxy import MutableProxy @@ -220,7 +220,7 @@ def test_fix_events_detaches_proxied_payload_values(): assert "rows" not in state.dirty_vars -def test_fix_events_detaches_nested_proxied_payload_values(): +def test_from_event_type_detaches_nested_proxied_payload_values(): """Proxies nested in plain containers are detached; clean parts shared.""" from reflex.istate.proxy import MutableProxy @@ -239,7 +239,7 @@ def test_fix_events_detaches_nested_proxied_payload_values(): assert state.rows == [{"a": 1}] -def test_fix_events_copies_opaque_payload_objects(): +def test_from_event_type_copies_opaque_payload_objects(): """Non-container mutable objects are still snapshotted by deepcopy.""" import dataclasses as dc @@ -255,6 +255,25 @@ class Opaque: assert obj.items == [1] +def test_detach_state_proxies_handles_cyclic_payloads(): + """Self-referential containers fall back to deepcopy, preserving cycles.""" + from reflex_base.event import _detach_state_proxies + + cyclic_list: list = [1] + cyclic_list.append(cyclic_list) + out = _detach_state_proxies(cyclic_list) + assert out is not cyclic_list + assert out[0] == 1 + assert out[1] is out + + cyclic_dict: dict = {"a": 1} + cyclic_dict["self"] = cyclic_dict + out = _detach_state_proxies(cyclic_dict) + assert out is not cyclic_dict + assert out["a"] == 1 + assert out["self"] is out + + @pytest.mark.parametrize( ("input", "output"), [ From 705600ec36f37615833ca11f1b2d0f2c7ca2e7da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:09:00 +0000 Subject: [PATCH 4/5] fix: update _detach_state_proxies call site to new signature Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx --- packages/reflex-base/src/reflex_base/event/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index c33778ff67d..919e1f2fd1f 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -148,7 +148,7 @@ def from_event_type( # ImmutableMutableProxy from a background task's StateProxy), # copying only subtrees that are actually proxied. payload = { - k._js_expr: _detach_state_proxies(v._decode(), {}) for k, v in e.args + k._js_expr: _detach_state_proxies(v._decode()) for k, v in e.args } # Create an event and append it to the list. From dbfafb08ddfb01bd60ae3d965a06f5397b3e3341 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:13:19 +0000 Subject: [PATCH 5/5] fix: reach _detach_state_proxies through module globals in test reflex_base.event replaces itself in sys.modules with the EventNamespace class, so private module names cannot be imported from it directly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sns7EuBPYvfGCxRSRZ1bUx --- tests/units/test_event.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 156b964db5f..83193b9e19c 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -257,18 +257,21 @@ class Opaque: def test_detach_state_proxies_handles_cyclic_payloads(): """Self-referential containers fall back to deepcopy, preserving cycles.""" - from reflex_base.event import _detach_state_proxies + # The reflex_base.event module replaces itself in sys.modules with the + # EventNamespace class, so private module names are only reachable + # through the globals of a function defined in that module. + detach = Event.from_event_type.__func__.__globals__["_detach_state_proxies"] cyclic_list: list = [1] cyclic_list.append(cyclic_list) - out = _detach_state_proxies(cyclic_list) + out = detach(cyclic_list) assert out is not cyclic_list assert out[0] == 1 assert out[1] is out cyclic_dict: dict = {"a": 1} cyclic_dict["self"] = cyclic_dict - out = _detach_state_proxies(cyclic_dict) + out = detach(cyclic_dict) assert out is not cyclic_dict assert out["a"] == 1 assert out["self"] is out