Skip to content
Open
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,17 @@ documentation — write it under `docs/` and let the fragment link there.
CI requires a fragment for every package whose source the PR touches; the
`skip-changelog` label waives it for changes that are genuinely not user-facing.

Which `news/` directory a fragment lands in is decided purely by changed path:
`reflex/**` -> repo-root `news/`; `packages/<name>/src/**` ->
`packages/<name>/news/`. A PR spanning `reflex/` and `packages/reflex-base/src/`
therefore needs a fragment in both. Paths outside those (tests, `docs/`, CI,
`scripts/`) require none on their own. Check the way CI does, once per affected
package:

```
uv run towncrier check --config pyproject.toml --dir <pkg-dir> --compare-with origin/main
```

## Breaking changes and deprecation

Reflex has downstream users — don't break them. Provide a fallback path during deprecation.
Expand Down
22 changes: 21 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,27 @@ Within the 'test' directory of Reflex you can add to a test file already there o

Each PR that changes the source of a published package must add a news fragment describing the change. Fragments are assembled into `CHANGELOG.md` at release time by [towncrier](https://towncrier.readthedocs.io/).

**Where:** add the fragment under the affected package's `news/` directory. For the main `reflex` package, that's the repo-root `news/`. For sub-packages it's `packages/<name>/news/`.
**Where: one fragment per affected package, not one per PR.** A PR that touches
two packages needs two fragments, one in each package's own `news/` directory.
CI decides which packages are affected purely from the changed paths:

| Changed path | Fragment belongs in |
| --- | --- |
| `reflex/**` | repo-root `news/` |
| `packages/<name>/src/**` | `packages/<name>/news/` |

So a PR editing both `reflex/state.py` and `packages/reflex-base/src/reflex_base/vars/base.py`
needs a fragment in `news/` **and** one in `packages/reflex-base/news/`. Changes
that touch neither path (tests, `docs/`, CI, `scripts/`) need no fragment on
their own. The `integrations-docs`, `reflex-components-internal` and
`reflex-site-shared` packages are never published, so they are always exempt.

Run the same check CI runs, once per affected package:

```bash
uv run towncrier check --config pyproject.toml --dir . --compare-with origin/main
uv run towncrier check --config pyproject.toml --dir packages/reflex-base --compare-with origin/main
```

**Filename:** `<pr-or-issue-number>.<type>.md`, where `<type>` is one of:

Expand Down
4 changes: 4 additions & 0 deletions docs/vars/computed_vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ expensive computations, but in some cases it may not update when you expect it t
To create a computed var that recomputes on every state update regardless of
dependencies, use `@rx.var(cache=False)`.

An uncached var is recomputed for every state update, but the recomputed value is
only sent to the frontend when it differs from the value that was last sent, so a
recomputation that yields the same value does not trigger a re-render.

Previous versions of Reflex had a `@rx.cached_var` decorator, which is now replaced
by the `cache` argument of `@rx.var` (which defaults to `True`).

Expand Down
1 change: 1 addition & 0 deletions news/+uncached-computed-var-delta-dedupe.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`@rx.var(cache=False)` vars now remember what they last sent to the frontend. They are still recomputed on every state update, but the value is only included in the delta when it actually changed, so an uncached var whose value stays the same no longer causes needless network traffic and re-renders.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
`ComputedVar` now records a key for the value an uncached (`cache=False`) var last sent to each client, so `BaseState.get_delta` can leave the var out of the delta when a recomputation produces the same value.
90 changes: 89 additions & 1 deletion packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import dataclasses
import datetime
import functools
import hashlib
import inspect
import json
import logging
Expand All @@ -25,6 +26,7 @@
Annotated,
Any,
ClassVar,
Final,
Generic,
Literal,
NoReturn,
Expand Down Expand Up @@ -53,7 +55,7 @@
VarDependencyError,
VarTypeError,
)
from reflex_base.utils.format import format_state_name
from reflex_base.utils.format import format_state_name, json_dumps
from reflex_base.utils.imports import (
ImmutableImportDict,
ImmutableParsedImportDict,
Expand Down Expand Up @@ -2249,6 +2251,47 @@ class FakeComputedVarBaseClass(property):
__pydantic_run_validation__ = False


# Marker for a value that has no delta key. Compared by identity and never stored
# on a state instance, so it is safe from serialization round trips.
_UNKEYABLE_VALUE: Final = object()

# Types whose instances are immutable and cheap to compare directly. float is
# deliberately absent: NaN is not equal to itself, so floats are keyed by their
# serialized form instead of comparing equal to nothing forever.
_ATOMIC_DELTA_VALUE_TYPES: Final = frozenset({str, int, bool, type(None)})

# Size of the digest used to key non-atomic delta values.
_DELTA_VALUE_DIGEST_SIZE: Final = 16


def _delta_value_key(value: Any) -> Any:
"""Get a comparable, alias-free key for a value going into a delta.

Immutable scalars are keyed by themselves, paired with their type so that
Python-equal but JSON-distinct values (``1`` and ``True``) do not collide.
Everything else is keyed by a digest of its serialized form: that is exactly
what the client receives, so a value without a registered serializer keys by
the ``null`` the client would get, it cannot be invalidated by a later
in-place mutation of the value, and it stays small no matter how big the
value is.

Args:
value: The value to key.

Returns:
The key, or ``_UNKEYABLE_VALUE`` if the value cannot be serialized.
"""
value_type = type(value)
if value_type in _ATOMIC_DELTA_VALUE_TYPES:
return (value_type, value)
try:
return hashlib.blake2b(
json_dumps(value).encode(), digest_size=_DELTA_VALUE_DIGEST_SIZE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a value has no registered serializer, json_dumps converts it to null instead of raising. _delta_value_key then reuses the null digest and drops later values, so non-serializable values are not always sent as documented; make unsupported serialization return _UNKEYABLE_VALUE, including for nested values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 2280:

<comment>When a value has no registered serializer, `json_dumps` converts it to `null` instead of raising. `_delta_value_key` then reuses the `null` digest and drops later values, so non-serializable values are not always sent as documented; make unsupported serialization return `_UNKEYABLE_VALUE`, including for nested values.</comment>

<file context>
@@ -2246,6 +2248,41 @@ class FakeComputedVarBaseClass(property):
+        return value
+    try:
+        return hashlib.blake2b(
+            json_dumps(value).encode(), digest_size=_DELTA_VALUE_DIGEST_SIZE
+        ).digest()
+    except Exception:
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back: this is the intended behavior, not a bug — though the docstring did read as if it were, and I've fixed that in a9748a8.

When a value has no registered serializer, the client receives null. That is true of the first delta and every later one. So two different unserializable objects are genuinely indistinguishable to the frontend, and suppressing the second one is correct: re-sending null over null is exactly the pointless re-render this PR exists to avoid. The comparison is deliberately "what would the client receive", not "is this the same Python object" — that's also what makes it immune to in-place mutation of a state-owned value.

_UNKEYABLE_VALUE is reserved for values where serialization actually raises (e.g. a circular structure), which is covered by test_uncached_computed_var_unkeyable_value_always_sent.

The docstring now says so explicitly rather than implying every unserializable value is unkeyable:

Everything else is keyed by a digest of its serialized form: that is exactly what the client receives, so a value without a registered serializer keys by the null the client would get [...]


Generated by Claude Code

).digest()
Comment thread
masenf marked this conversation as resolved.
except Exception:
return _UNKEYABLE_VALUE


def is_computed_var(obj: Any) -> TypeGuard[ComputedVar]:
"""Check if the object is a ComputedVar.

Expand Down Expand Up @@ -2483,6 +2526,51 @@ def _last_updated_attr(self) -> str:
"""
return f"__last_updated_{self._js_expr}"

@property
def _last_delta_key_attr(self) -> str:
"""The attribute used to store the key of the last value sent in a delta.

Returns:
An attribute name.
"""
return f"__last_delta_{self._js_expr}"
Comment thread
masenf marked this conversation as resolved.

def _record_delta_value(self, instance: BaseState, value: Any, token: str) -> bool:
"""Record the value an uncached var contributes to the delta.

Uncached vars are recomputed for every delta, but recomputing does not
imply the value changed. Keeping a key for the last value that was sent
to the client allows an unchanged value to be omitted from the delta,
avoiding a needless re-render on the frontend.

The client token is recorded alongside the key because a single state
instance can serve several clients (linked shared states): a value that
was already sent to one client still has to be sent to the others.

Args:
instance: The state instance that the computed var is attached to.
value: The freshly computed value.
token: The client token the delta is being produced for.

Returns:
Whether the value differs from the last recorded value and should
therefore be included in the delta.
"""
attr = self._last_delta_key_attr
key = _delta_value_key(value)
if key is _UNKEYABLE_VALUE:
# The value can never be compared, so it always has to be sent.
with contextlib.suppress(AttributeError):
delattr(instance, attr)
return True
recorded = (token, key)
if getattr(instance, attr, None) == recorded:
Comment thread
masenf marked this conversation as resolved.
return False
setattr(instance, attr, recorded)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This dynamically generated tracking attribute bypasses BaseState's reserved-field checks, allowing a colliding internal name to overwrite delta bookkeeping silently. Define the bookkeeping as a reserved non-var state field or reserve the generated names explicitly.

(Based on your team's feedback about reserved internal state tracking fields.)

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 2560:

<comment>This dynamically generated tracking attribute bypasses BaseState's reserved-field checks, allowing a colliding internal name to overwrite delta bookkeeping silently. Define the bookkeeping as a reserved non-var state field or reserve the generated names explicitly.

(Based on your team's feedback about reserved internal state tracking fields.) </comment>

<file context>
@@ -2480,6 +2517,51 @@ def _last_updated_attr(self) -> str:
+        recorded = (token, key)
+        if getattr(instance, attr, None) == recorded:
+            return False
+        setattr(instance, attr, recorded)
+        # Ensure the recorded value gets serialized to redis.
+        instance._was_touched = True
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back: this is the established convention for per-var bookkeeping on a state instance, not something new here.

ComputedVar already stores __cached_{js_expr} and __last_updated_{js_expr} on the instance the same way; __last_delta_{js_expr} is the third of the set. The __ prefix is what routes all three through the BaseState.__setattr__ / __getattribute__ fast path, deliberately, so they never masquerade as state vars.

For a collision, a user would have to declare a var literally named __last_delta_<name>_rx_state_ (_js_expr already carries FIELD_MARKER) — and a name starting with __ cannot be declared as a state var in the first place.

Promoting these to reserved non-var state fields is a reasonable idea, but it should cover all three attributes at once and is a change to BaseState's field machinery, not something to bundle into a perf fix. Noted as follow-up work on the PR.


Generated by Claude Code

# Ensure the recorded value gets serialized to redis.
instance._was_touched = True
Comment on lines +2566 to +2571

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When linked clients alternate deltas, this stores only the most recent client token rather than one key per client. After both clients receive the same value, alternating requests resend it indefinitely; store a token-to-key mapping.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/reflex-base/src/reflex_base/vars/base.py, line 2557:

<comment>When linked clients alternate deltas, this stores only the most recent client token rather than one key per client. After both clients receive the same value, alternating requests resend it indefinitely; store a token-to-key mapping.</comment>

<file context>
@@ -2480,6 +2517,51 @@ def _last_updated_attr(self) -> str:
+            with contextlib.suppress(AttributeError):
+                delattr(instance, attr)
+            return True
+        recorded = (token, key)
+        if getattr(instance, attr, None) == recorded:
+            return False
</file context>
Suggested change
recorded = (token, key)
if getattr(instance, attr, None) == recorded:
return False
setattr(instance, attr, recorded)
# Ensure the recorded value gets serialized to redis.
instance._was_touched = True
recorded_values = getattr(instance, attr, {})
if recorded_values.get(token) == key:
return False
recorded_values[token] = key
setattr(instance, attr, recorded_values)
# Ensure the recorded value gets serialized to redis.
instance._was_touched = True

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct diagnosis, but I'm not taking the suggested fix in this PR.

A plain {token: key} dict grows one entry per client that ever touched the shared state, is persisted to redis with the state, and has no eviction — so a long-lived shared state accumulates keys for every client that has ever connected, including long-disconnected ones. Trading an unbounded, persisted map for an optimization in the multi-client-alternating case isn't a good deal without a bound and an eviction policy, and that's a bigger design decision than belongs in this PR.

The current behavior in that case is a missed optimization, not a correctness bug: the key thrashes between tokens, so the value is resent — which is exactly what happens today on main.

Filed as follow-up work in the PR discussion (bounded per-client key map with LRU eviction).


Generated by Claude Code

return True

def needs_update(self, instance: BaseState) -> bool:
"""Check if the computed var needs to be updated.

Expand Down
3 changes: 2 additions & 1 deletion reflex/istate/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ async def _patch_state(
root_state.dirty_vars.add("router")
root_state.dirty_vars.add(ROUTER_DATA)
root_state._mark_dirty()
await root_state._get_resolved_delta()
# The delta is discarded: it is only resolved to refresh computed vars.
await root_state._get_resolved_delta(record_values=False)
yield
finally:
original_parent_state.substates[state_name] = original_state
Expand Down
66 changes: 56 additions & 10 deletions reflex/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import re
import sys
import time
from collections.abc import Callable, Iterator, Mapping, Sequence
from collections.abc import Callable, Coroutine, Iterator, Mapping, Sequence
from hashlib import md5
from types import FunctionType
from typing import (
Expand Down Expand Up @@ -309,6 +309,30 @@ async def _resolve_delta(delta: Delta) -> Delta:
return delta


async def _drop_unchanged_delta_value(
cvar: ComputedVar,
instance: BaseState,
value: Coroutine[None, None, Any],
token: str,
) -> Any:
"""Await an async uncached computed var, dropping it if the value did not change.

Args:
cvar: The computed var that produced the coroutine.
instance: The state instance the computed var is attached to.
value: The coroutine returned by the computed var.
token: The client token the delta is being produced for.

Returns:
The resolved value, or ``_DROP_FROM_DELTA`` when it matches the last
value that was sent to the client.
"""
resolved = await value
if not cvar._record_delta_value(instance, resolved, token):
return _DROP_FROM_DELTA
return resolved


RETURN = TypeVar("RETURN")
PARAMS = ParamSpec("PARAMS")

Expand Down Expand Up @@ -1954,9 +1978,15 @@ def _dirty_computed_vars(
if include_backend or not self.computed_vars[cvar]._backend
}

def get_delta(self) -> Delta:
def get_delta(self, record_values: bool = True) -> Delta:
"""Get the delta for the state.

Args:
record_values: Whether the values of uncached computed vars should be
recorded as sent to the client. Pass False when the delta is
computed for its side effects and then discarded, otherwise the
unsent values would be omitted from the next delta.

Returns:
The delta for the state.
"""
Expand All @@ -1973,30 +2003,46 @@ def get_delta(self) -> Delta:
self.dirty_vars.intersection(frontend_computed_vars)
)

subdelta: dict[str, Any] = {
prop + FIELD_MARKER: self.get_value(prop)
for prop in delta_vars
if not types.is_backend_base_variable(prop, type(self))
}
always_dirty_computed_vars = self._always_dirty_computed_vars
# Token of the client this delta is for, used to know which values it has.
token = self.router.session.client_token if always_dirty_computed_vars else ""
Comment thread
masenf marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Derive the delta token from the root state rather than the patched shared substate. During linked-state fan-out, self.router can still belong to the origin state, causing the recipient to reuse the origin's recorded key and omit its required update.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At reflex/state.py, line 1914:

<comment>Derive the delta token from the root state rather than the patched shared substate. During linked-state fan-out, `self.router` can still belong to the origin state, causing the recipient to reuse the origin's recorded key and omit its required update.</comment>

<file context>
@@ -1879,30 +1909,46 @@ def get_delta(self) -> Delta:
-        }
+        always_dirty_computed_vars = self._always_dirty_computed_vars
+        # Token of the client this delta is for, used to know which values it has.
+        token = self.router.session.client_token if always_dirty_computed_vars else ""
+        subdelta: dict[str, Any] = {}
+        for prop in delta_vars:
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushing back: the suggested change is a no-op, because self.router on a substate already resolves to the root state.

router is in every substate's inherited_vars, so BaseState.__getattribute__ forwards the lookup up the parent_state chain to the root — self.router and self._get_root_state().router are the same object by construction:

router inherited on Sub   : True
sub.router token          : client_b
sub._get_root_state token : client_b
identical                 : True

On the fan-out concern specifically: _patch_state reassigns linked_state.parent_state = original_parent_state before any delta is computed, and original_parent_state belongs to the tree of the client the delta is being built for. _do_update_other_tokens -> _update_client(token) enters app.modify_state for that client's token and re-patches the shared state into that client's tree, so self.router resolves to the recipient's token, not the origin's. The origin's token is only reachable while the origin's own delta is being built, which is correct.

(The linked_root_state.router rewrite in _internal_patch_linked_state targets the linked token's own root, which is not the tree the delta recurses through.)


Generated by Claude Code

subdelta: dict[str, Any] = {}
for prop in delta_vars:
if types.is_backend_base_variable(prop, type(self)):
continue
value = self.get_value(prop)
if record_values and prop in always_dirty_computed_vars:
# Uncached computed vars are recomputed for every delta; only
# send them when the recomputed value actually changed.
cvar = self.computed_vars[prop]
if inspect.iscoroutine(value):
value = _drop_unchanged_delta_value(cvar, self, value, token)
elif not cvar._record_delta_value(self, value, token):
continue
subdelta[prop + FIELD_MARKER] = value

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

# Recursively find the substate deltas.
substates = self.substates
for substate in self.dirty_substates.union(self._always_dirty_substates):
delta.update(substates[substate].get_delta())
delta.update(substates[substate].get_delta(record_values=record_values))

# Return the delta.
return delta

async def _get_resolved_delta(self) -> Delta:
async def _get_resolved_delta(self, record_values: bool = True) -> Delta:
"""Get the delta for the state after resolving all coroutines.

Args:
record_values: Whether the values of uncached computed vars should be
recorded as sent to the client. See `get_delta`.

Returns:
The resolved delta for the state.
"""
return await _resolve_delta(self.get_delta())
return await _resolve_delta(self.get_delta(record_values=record_values))

def _mark_dirty(self):
"""Mark the substate and all parent states as dirty."""
Expand Down
Loading
Loading