Skip to content
1 change: 1 addition & 0 deletions news/6906.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
State deltas no longer re-ship the session block and request headers on every navigation; only the router fields that changed (page, url, route_id) are sent, cutting the per-navigation delta by ~72%.
1 change: 1 addition & 0 deletions packages/reflex-base/news/6906.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
State deltas no longer re-ship the session block and request headers on every navigation; only the router fields that changed (page, url, route_id) are sent, cutting the per-navigation delta by ~72%.
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,33 @@ export const isStateful = () => {
return event_queue.some((event) => event.name.startsWith("reflex___state"));
};

// Root-state field carrying RouterData; must match `constants.ROUTER +
// FIELD_MARKER` on the backend (see reflex.istate.data).
const ROUTER_FIELD = "router_rx_state_";

/**
* Apply a delta to the state.
* @param state The state to apply the delta to.
* @param delta The delta to apply.
*/
export const applyDelta = (state, delta) => {
return { ...state, ...delta };
const new_state = { ...state, ...delta };
// Once the connection-scoped router fields (session, headers) have been
// sent, the backend elides them from subsequent deltas; merge partial
// router payloads over the previously received value so they carry
// forward. The merge is field-agnostic, so adding RouterData fields on the
// backend needs no change here.
const router = delta[ROUTER_FIELD];
const prev_router = state[ROUTER_FIELD];
if (
router !== null &&
prev_router !== null &&
typeof router === "object" &&
typeof prev_router === "object"
) {
new_state[ROUTER_FIELD] = { ...prev_router, ...router };
}
return new_state;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from importlib.util import find_spec
from typing import TYPE_CHECKING, Any

from reflex.istate.data import RouterData
from reflex.istate.data import RouterData, router_connection_scope
from reflex.istate.manager.token import BaseStateToken
from reflex.istate.proxy import StateProxy
from reflex.utils import types
Expand Down Expand Up @@ -358,11 +358,19 @@ async def _execute_event(

# re-assign only when the value is set and different
if router_data and state.router_data != router_data:
previous_router = state.router
# assignment will recurse into substates and force recalculation of
# dependent ComputedVar (dynamic route variables)
state.router_data = router_data
if state.router != (router := RouterData.from_router_data(router_data)):
state.router = router
# When only the per-navigation fields changed, the delta
# can elide the connection-scoped fields the client
# already holds. Direct router writes elsewhere reset
# this flag (see BaseState.__setattr__).
state._router_static_unchanged = router_connection_scope(
Comment thread
Alek99 marked this conversation as resolved.
previous_router
) == router_connection_scope(router)

# Preprocess the event.
if (
Expand Down
4 changes: 4 additions & 0 deletions packages/reflex-base/src/reflex_base/utils/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ class VarNameError(ReflexError, NameError):
"""Custom NameError for when a state var has been shadowed by a substate var."""


class ReservedStateFieldError(ReflexError, NameError):
"""Raised when a state class declares a field name reserved for internal use."""


class VarTypeError(ReflexError, TypeError):
"""Custom TypeError for var related errors."""

Expand Down
9 changes: 8 additions & 1 deletion packages/reflex-base/src/reflex_base/utils/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,14 @@ def __call__(
dict: Dict, # noqa: UP006
}

RESERVED_BACKEND_VAR_NAMES = {"_abc_impl", "_backend_vars", "_was_touched", "_mixin"}
RESERVED_BACKEND_VAR_NAMES = {
"_abc_impl",
"_backend_vars",
"_partial_router_capable",
"_router_static_unchanged",
"_was_touched",
"_mixin",
}


class Unset:
Expand Down
26 changes: 20 additions & 6 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1990,17 +1990,25 @@ async def on_connect(self, sid: str, environ: dict):
self._token_manager.ensure_lost_and_found_task(self.emit_update)
query_params = urllib.parse.parse_qs(environ.get("QUERY_STRING", ""))
token_list = query_params.get("token", [])
if token_list:
await self.link_token_to_sid(sid, token_list[0])
else:
logger.warning(f"No token provided in connection for session {sid}")

subprotocol = environ.get("HTTP_SEC_WEBSOCKET_PROTOCOL")
if subprotocol and subprotocol != constants.Reflex.VERSION:
logger.warning(
f"Frontend version {subprotocol} for session {sid} does not match the backend version {constants.Reflex.VERSION}."
)

if token_list:
await self.link_token_to_sid(
sid,
token_list[0],
# Only a frontend compiled by this exact backend version is
# known to merge partial router deltas; anything else (e.g. a
# cached bundle from before a rolling deployment) gets the
# full router in every delta.
partial_router_capable=subprotocol == constants.Reflex.VERSION,
)
else:
logger.warning(f"No token provided in connection for session {sid}")

def on_disconnect(self, sid: str) -> asyncio.Task | None:
"""Event for when the websocket disconnects.

Expand Down Expand Up @@ -2232,12 +2240,17 @@ async def on_client_error(self, sid: str, data: Any):
# handlers (e.g. error trackers) receive client errors too.
self.app.frontend_exception_handler(Exception(report))

async def link_token_to_sid(self, sid: str, token: str):
async def link_token_to_sid(
self, sid: str, token: str, partial_router_capable: bool = False
):
"""Link a token to a session id.

Args:
sid: The Socket.IO session id.
token: The client token.
partial_router_capable: Whether the connected frontend advertised
(via exact version match on the websocket subprotocol) that it
merges partial router deltas.
"""
# Use TokenManager for duplicate detection and Redis support
new_token = await self._token_manager.link_token_to_sid(token, sid)
Expand All @@ -2253,3 +2266,4 @@ async def link_token_to_sid(self, sid: str, token: str):
) as state:
state.router_data[constants.RouteVar.SESSION_ID] = sid
state.router = RouterData.from_router_data(state.router_data)
state._partial_router_capable = partial_router_capable
47 changes: 45 additions & 2 deletions reflex/istate/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,26 @@ def from_router_data(cls, router_data: dict) -> "RouterData":
)


# RouterData fields that are fixed for the lifetime of a client connection.
# State deltas may omit these once the client has them (see
# serialize_partial_router_data); the event processor decides that by
# comparing exactly these fields, so adding one here is all that is needed to
# keep the two in step.
CONNECTION_SCOPED_ROUTER_FIELDS = ("session", "headers")


def router_connection_scope(obj: RouterData) -> tuple:
"""Get the connection-scoped router values used to detect client-visible changes.

Args:
obj: the RouterData object.

Returns:
A tuple of the connection-scoped field values, in declaration order.
"""
return tuple(getattr(obj, name) for name in CONNECTION_SCOPED_ROUTER_FIELDS)


@serializer(to=dict)
def serialize_router_data(obj: RouterData) -> dict:
"""Serialize a RouterData object to a dict.
Expand All @@ -472,8 +492,31 @@ def serialize_router_data(obj: RouterData) -> dict:
A dict representation of the RouterData object.
"""
return {
"session": obj.session,
"headers": obj.headers,
**dict(
zip(
CONNECTION_SCOPED_ROUTER_FIELDS,
router_connection_scope(obj),
strict=True,
)
),
**serialize_partial_router_data(obj),
}


def serialize_partial_router_data(obj: RouterData) -> dict:
"""Serialize only the per-navigation fields of a RouterData object.

Used for state deltas once the connection-scoped fields (session and
headers) have already been sent to the client; the frontend merges this
partial payload over its previously received router value.

Args:
obj: the RouterData object.

Returns:
A dict with the per-navigation fields of the RouterData object.
"""
return {
"page": obj._page,
# ReflexURL is a str subclass, so json.dumps handles it natively and
# never invokes the `default=serialize` hook. Call the URL serializer
Expand Down
69 changes: 68 additions & 1 deletion reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
DynamicRouteArgShadowsStateVarError,
EventHandlerShadowsBuiltInStateMethodError,
ReflexRuntimeError,
ReservedStateFieldError,
SetUndefinedStateVarError,
StateMismatchError,
StateSchemaMismatchError,
Expand Down Expand Up @@ -73,7 +74,7 @@
import reflex.istate.dynamic
from reflex import event
from reflex.istate import HANDLED_PICKLE_ERRORS, debug_failed_pickles
from reflex.istate.data import RouterData
from reflex.istate.data import RouterData, serialize_partial_router_data
from reflex.istate.proxy import ImmutableMutableProxy as ImmutableMutableProxy
from reflex.istate.proxy import MutableProxy, is_mutable_type
from reflex.istate.storage import ClientStorageBase
Expand Down Expand Up @@ -441,6 +442,19 @@ class BaseState(EvenMoreBasicBaseState):
# Whether the state has ever been touched since instantiation.
_was_touched: bool = field(default=False, is_var=False)

# Whether the event processor's last router reassignment left the
# connection-scoped fields (session, headers) unchanged. Transient:
# recomputed on every reassignment, cleared by any direct router write,
# and never pickled.
_router_static_unchanged: bool = field(default=False, is_var=False)

# Whether the connected client advertised, via an exact version match on
# the websocket subprotocol, that its applyDelta merges partial router
# payloads. Set on connect; False for older frontends (e.g. cached
# bundles during a rolling deployment), which then receive the full
# router in every delta.
_partial_router_capable: bool = field(default=False, is_var=False)

# A special event handler for setting base vars.
setvar: ClassVar[EventHandler]

Expand Down Expand Up @@ -547,6 +561,12 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs):

super().__init_subclass__(**kwargs)

# Internal router bookkeeping fields must not be redefined by user
# states: a shadowing value would silently control whether router
# deltas are sent partially. Checked before the mixin early-return so
# a mixin cannot smuggle the field into concrete states.
cls._check_reserved_internal_fields()

if cls._mixin:
return

Expand Down Expand Up @@ -962,6 +982,29 @@ def _check_overridden_methods(cls):
msg = f"The event handler name `{method_name}` shadows a builtin State method; use a different name instead"
raise EventHandlerShadowsBuiltInStateMethodError(msg)

_RESERVED_INTERNAL_FIELD_NAMES = frozenset({
"_partial_router_capable",
"_router_static_unchanged",
})

@classmethod
def _check_reserved_internal_fields(cls):
"""Check that internal bookkeeping fields are not redefined.

Raises:
ReservedStateFieldError: When a state class declares a field
reserved for internal use.
"""
declared = set(inspect.get_annotations(cls)) | {
name for name in cls._RESERVED_INTERNAL_FIELD_NAMES if name in cls.__dict__
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
for name in cls._RESERVED_INTERNAL_FIELD_NAMES & declared:
msg = (
f"The field name `{name}` in {cls.__module__}.{cls.__name__} is "
"reserved for internal use; use a different name instead"
)
raise ReservedStateFieldError(msg)

@classmethod
def _check_overridden_basevars(cls):
"""Check for shadow base vars and raise error if any.
Expand Down Expand Up @@ -1560,6 +1603,12 @@ def __setattr__(self, name: str, value: Any):
self.dirty_vars.add(name)
self._mark_dirty()

# Any direct router write invalidates the partial-router-delta
# optimization; the event processor re-arms it after comparing the
# connection-scoped fields (see BaseStateEventProcessor).
if name == constants.ROUTER:
object.__setattr__(self, "_router_static_unchanged", False)

def reset(self):
"""Reset all the base vars to their default values."""
# Reset the base vars.
Expand Down Expand Up @@ -1885,6 +1934,20 @@ def get_delta(self) -> Delta:
if not types.is_backend_base_variable(prop, type(self))
}

if (
self.parent_state is None
and (router_field := constants.ROUTER + FIELD_MARKER) in subdelta
and self._router_static_unchanged
and self._partial_router_capable
):
# The connection-scoped router fields (session, headers) this
# client already received are unchanged, so ship only the
# per-navigation fields; the frontend merges the partial payload
# over its previously received router value.
subdelta[router_field] = serialize_partial_router_data(
subdelta[router_field]
)

if len(subdelta) > 0:
delta[self.get_full_name()] = subdelta

Expand Down Expand Up @@ -2075,6 +2138,10 @@ def __getstate__(self):
state.pop("parent_state", None)
state.pop("substates", None)
state.pop("_was_touched", None)
# Transient, request-scoped: recomputed by the event processor on every
# router reassignment. Persisting it could arm a partial router delta
# for a client that never received the connection-scoped fields.
state.pop("_router_static_unchanged", None)
# Remove all inherited vars.
for inherited_var_name in self.inherited_vars:
state.pop(inherited_var_name, None)
Expand Down
Loading
Loading