Skip to content

Commit 064ba67

Browse files
masenfclaude
andauthored
Back out class-level default assignment; refuse to assign over a state var (#7516)
* Back out class-level default assignment; refuse to assign over a state var #7461 made assigning a declared var through its state class update the var's default, and #7495 added patch/restore history and kept browser storage on plain assignments. That layer on top of the Field descriptor brought more edge cases than this release should carry, so it comes out. BaseStateMeta.__setattr__ now raises TypeError when the name resolves to a state var's field, instead of either replacing the descriptor (which broke the var, as on 0.10.0a1) or updating its default. Assigning the var's own field back is a no-op, so a patch whose assignment failed cleans up quietly; deleting the attribute first still replaces the var on purpose. ClassVars and new names stay assignable, and add_var's new fields bind through the guard because add_var rejects existing names. Defaults are configured on the field instead: State.__fields__["x"].default = value, or default_factory. Tests patch the field rather than the class attribute. The ComponentState docs go back to the __fields__ pattern, now with a note on per-component browser storage keys. Kept from #7461: the saved-state schema without defaults (and its legacy fallback), reset() using default_value(), declared storage factories in compile_client_storage, and the TypeVar/Protocol handling in _isinstance. Kept from #7495: the dev-mode guard against undeclared mangled names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Make the class-default docs examples self-contained The base vars example set a factory on a field TickerState does not have, and the ComponentState storage example used get_component arguments that were never declared. Both now declare what they use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Format the ComponentState storage example Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Name the var when a patch through a substate is refused mock.patch.object on a var a substate inherits was refused by the guard, but its cleanup then deleted the attribute it never set on the substate, and the resulting AttributeError hid the TypeError. Deleting an inherited var through a class now raises the same TypeError, built by one helper. The base vars docs now say what a plain string default does to a browser storage var: a str-annotated var becomes an ordinary var, a storage-typed one stays in storage under the default key and options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Mention factory-backed storage defaults in the base vars docs A var annotated with a storage type also keeps its storage name and options when its default_factory returns a storage value, which the compiler reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Show only working code in the class-default docs examples Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Note that each backend worker holds its own ClassVar value A ClassVar set at import time starts the same in every worker, but a lock only serializes its own worker's sessions and a change made after the workers start stays in that worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo * Point cross-session runtime values to SharedState, not a state var A regular state var is per session, so it cannot carry a value every session sees; a linked rx.SharedState or an external store can. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9b81d8c commit 064ba67

21 files changed

Lines changed: 303 additions & 1353 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Document changing a state var's default through its field, including per-component defaults and browser storage keys in `ComponentState`.

‎docs/changelog/upgrading/upgrading-to-0-10.md‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,32 @@ def page():
4444
State.get_fields()["_items"].default_value()
4545
```
4646

47-
## Assigning a default through a state class
47+
## Assigning a state var through its class
4848

49-
On 0.9, `State.count = 10` replaced the class attribute, but new instances still started at the declared default. In 0.10 it updates the var's default and the var stays a var ([#7461](https://github.com/reflex-dev/reflex/pull/7461)); [Changing Defaults](/docs/vars/base-vars/#changing-defaults) has the full rules. Check these before relying on it:
49+
On 0.9, `State.count = 10` replaced the class attribute, but new instances still started at the declared default. In 0.10 a state var is a descriptor on its class, and assigning over it raises `TypeError` instead of breaking the var. This includes pytest's `monkeypatch.setattr` and `unittest.mock.patch.object` on a var. Change the default on the var's field, patch the field in tests, and declare class-level configuration as `ClassVar`:
5050

51-
- It changes the default for values not yet stored on an instance, which includes every new session and `reset()`. A value already stored on an instance keeps it.
52-
- The value must satisfy the var's annotation, or `TypeError: Invalid default for field` is raised. An unannotated var is typed from its default, so `_client = None` accepts only `None`; annotate the var with the type you assign.
53-
- A zero-argument callable that the annotation does not accept, such as a function or a class, is called once during the assignment to check what it returns, then becomes the default factory. Do not assign something whose call has side effects.
54-
- Mutable defaults are copied for every instance. A live client, lock or connection assigned to an `Any` or `Optional[...]` var is accepted, but reading the var on a new instance then raises `TypeError: cannot pickle '_thread.lock' object`. Declare an object that all sessions share as `ClassVar[...]`, which is never copied.
55-
- Assigning on a mixin only affects states created afterwards. Do not assign defaults in event handlers, lifespan tasks, or at any other time after the app has started running: such an assignment only affects the worker process that ran it.
51+
```python
52+
from typing import ClassVar
53+
from unittest import mock
54+
55+
import httpx
56+
57+
import reflex as rx
58+
59+
60+
class State(rx.State):
61+
count: int = 0
62+
_client: ClassVar[httpx.AsyncClient | None] = None
63+
64+
65+
State.__fields__["count"].default = 10
66+
State._client = httpx.AsyncClient() # a ClassVar stays an ordinary class attribute
67+
68+
with mock.patch.object(State.__fields__["count"], "default", 99):
69+
...
70+
```
71+
72+
[Changing Defaults](/docs/vars/base-vars/#changing-defaults) has the details, including mutable defaults and browser storage vars.
5673

5774
## Calling inherited handlers from background tasks
5875

‎docs/state_structure/component_state.md‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ class EditableText(rx.ComponentState):
108108
# Set the initial value of the State var.
109109
initial_value = props.pop("initial_value", None)
110110
if initial_value is not None:
111-
cls.text = initial_value
111+
cls.__fields__["text"].default = initial_value
112112

113113
# Form elements for editing, saving and reverting the text.
114114
edit_controls = rx.hstack(
@@ -160,10 +160,29 @@ def editable_text_example():
160160
)
161161
```
162162

163-
Assigning `cls.text` in `get_component` sets the default for that component's new
164-
state class. Other components keep their own defaults, and `cls.text` still
165-
references the frontend var when building the UI. Resetting the component's state
166-
restores its last configured default.
163+
Setting `cls.__fields__["text"].default` in `get_component` sets the default for
164+
that component's new state class, which owns a copy of each field: other
165+
components keep their own defaults. Resetting the component's state restores the
166+
configured default. Assigning `cls.text` itself raises `TypeError`, because
167+
`cls.text` is the var the UI references; see
168+
[Changing Defaults](/docs/vars/base-vars/#changing-defaults).
169+
170+
A browser storage var declared with a `name`, such as
171+
`rx.LocalStorage("light", name="theme")`, stores every component's value under
172+
that one key, so the components overwrite each other's stored value. To persist a
173+
value per component, give each component its own storage value in
174+
`get_component`, here named by a `key` prop that identifies the component, or
175+
leave `name` unset so each component state gets its own key:
176+
177+
```python
178+
class ThemeToggle(rx.ComponentState):
179+
theme: str = rx.LocalStorage("light", name="theme")
180+
181+
@classmethod
182+
def get_component(cls, key: str, initial: str = "light", **props):
183+
cls.__fields__["theme"].default = rx.LocalStorage(initial, name=f"theme_{key}")
184+
return rx.text(cls.theme, **props)
185+
```
167186

168187
```python eval
169188
rx.divider()

‎docs/vars/base_vars.md‎

Lines changed: 56 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -75,59 +75,65 @@ def ticker_example():
7575

7676
## Changing Defaults
7777

78-
Assigning a value to a declared var on its state class updates the default for
79-
values that have not yet been initialized and for `reset()`. This works for both
80-
frontend and backend vars. Values already stored on a state instance stay the
81-
same, and mutable defaults are copied for each instance. Reset uses the last
82-
configured default. Defaults are not part of the saved-state schema, so changing
83-
one does not invalidate state saved by this release or later.
84-
85-
Assigned defaults must match the field's declared type. Var and Field assignments
86-
are rejected, including fresh `rx.field(...)` objects, unless they restore a
87-
default as described below. Declare a `ClassVar[rx.Var]` to
88-
store a Var reference, or define a computed var to read another field at runtime.
89-
A callable the annotation accepts, such as for a `Callable` or `Any` var, is
90-
stored as the default. Otherwise, assigning a zero-argument callable updates the
91-
default factory: Reflex calls it once when assigned to validate its result, then
92-
calls it whenever an instance needs a new default or resets. If that validation
93-
call fails or returns an invalid default, the previous default remains in place.
94-
A factory that produces a browser storage value is called once at assignment,
95-
and its result becomes the default so the storage name and options are kept.
96-
Assigning a plain string to a var whose default is a browser storage value, such
97-
as `rx.LocalStorage("light", name="theme")`, or whose `default_factory` produces
98-
one, keeps that storage type, name and options and changes only the value.
99-
Frontend vars remain usable in the UI after assigning a new default value or
100-
factory.
101-
102-
An inherited var belongs to the state that declared it, so assigning a default
103-
through a subclass also changes that declaring state's default. Each generated
104-
`ComponentState` class owns its copied fields, allowing
78+
A state var is a descriptor on its state class, so assigning to it through the
79+
class raises `TypeError` instead of replacing the var. To change a var's default,
80+
set it on the var's field:
81+
82+
```python
83+
class WatchlistState(rx.State):
84+
ticker: str = "AAPL"
85+
symbols: list[str] = []
86+
87+
88+
WatchlistState.__fields__["ticker"].default = "MSFT"
89+
WatchlistState.__fields__["symbols"].default_factory = lambda: ["AAPL", "MSFT"]
90+
```
91+
92+
The default applies to values not yet stored on an instance, which includes
93+
every new session and `reset()`. Values already stored on an instance stay the
94+
same. A field uses its `default` when one is set, and otherwise calls its
95+
`default_factory`. A `default` is shared by every instance, so give a mutable
96+
default a `default_factory` that builds a new value each time. The field does
97+
not check the value against the var's annotation. A browser storage var keeps
98+
its storage name and options only with a storage value as its default, such as
99+
`rx.LocalStorage("dark", name="theme")`, or, for a var annotated with a storage
100+
type, a `default_factory` that returns one. Given a plain string, a var
101+
annotated `str` becomes an ordinary var, and one annotated with a storage type,
102+
such as `rx.LocalStorage`, stays in browser storage under the default key and
103+
options.
104+
105+
An inherited var belongs to the state that declared it, so changing its field
106+
also changes the default for every state that inherits it. Each generated
107+
`ComponentState` class owns copies of its fields, allowing
105108
[`get_component` to configure defaults](/docs/state-structure/component-state/#passing-props)
106109
independently for each component.
107110

108-
Assigning a var's own field or Var back to its state class, as read through the
109-
class before a change, or deleting the class attribute undoes the most recent
110-
default assignment. Testing tools such as pytest's `monkeypatch.setattr` and
111-
`unittest.mock.patch.object` restore a patched default this way, including a
112-
patch made through a subclass.
111+
In tests, patch the field rather than the class attribute, which raises the same
112+
`TypeError`:
113113

114-
```md alert warning
115-
# Annotate the var, and keep shared objects out of defaults.
116-
117-
An unannotated var is typed from its default, so `_client = None` only accepts
118-
`None` later: annotate the var with the type you will assign. Defaults are copied
119-
for every instance, so a live client, lock, or connection cannot be shared this
120-
way. Assigning one to an `Any` or `Optional[...]` var is accepted, but reading the
121-
var on a new instance then raises `TypeError: cannot pickle '_thread.lock' object`.
122-
Declare an object that all sessions share as a `ClassVar`.
114+
```python
115+
from unittest import mock
116+
117+
with mock.patch.object(WatchlistState.__fields__["ticker"], "default", "MSFT"):
118+
...
123119
```
124120

121+
Declare class-level configuration, such as a client or a lock, as a `ClassVar`.
122+
It stays an ordinary class attribute, so assigning it through the class is
123+
allowed, and it is never copied for each instance. Each backend worker process
124+
holds its own value, though, so a lock only serializes the sessions on its own
125+
worker. A value set at import time, such as at module level, starts the same in
126+
every worker, but a change made after the workers start, such as in an event
127+
handler, never reaches the other workers. Keep a value that sessions must share
128+
at runtime in an [`rx.SharedState`](/docs/state-structure/shared-state/) or an
129+
external store.
130+
125131
```md alert warning
126-
# Assign defaults before the app starts running.
132+
# Change defaults before the app starts running.
127133

128-
Assigning on a mixin only affects states created afterwards. Do not assign
129-
defaults in event handlers, lifespan tasks, or at any other time after the app
130-
has started running: such an assignment only affects the worker process that ran
134+
Changing a default on a mixin only affects states created afterwards. Do not
135+
change defaults in event handlers, lifespan tasks, or at any other time after the
136+
app has started running: such a change only affects the worker process that made
131137
it.
132138
```
133139

@@ -164,9 +170,10 @@ Reading `MyState._token` through the class returns its field descriptor, whose
164170
`default_value()` method returns the default (a fresh copy if it is mutable). Use
165171
it to build the UI from a constant, such as
166172
`rx.foreach(MyState._options.default_value(), rx.text)` for a backend var
167-
`_options`. Assigning to `MyState._token` updates its default as described above.
173+
`_options`. Assigning to `MyState._token` raises `TypeError`: change its default
174+
through its field as described above.
168175

169-
For configuration shared by all sessions, declare a `ClassVar` instead:
176+
For class-level configuration, declare a `ClassVar` instead:
170177

171178
```python
172179
from typing import ClassVar
@@ -183,6 +190,8 @@ MyState._endpoint = "https://example.com/v2"
183190
`ClassVar` values are ordinary class attributes and are not part of session state.
184191
Reading one on the class returns the plain value, and an object that cannot be
185192
copied, such as a client or lock, is shared rather than copied for each instance.
193+
Each backend worker holds its own value, as described under
194+
[Changing Defaults](#changing-defaults).
186195

187196
```python demo exec
188197
import numpy as np
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Configure per-component defaults in `ComponentState.get_component` with assignments such as `cls.count = 10`. Reset restores the last configured default. Defaults are no longer part of the saved-state schema, so changing one keeps state saved by this release or later loadable. Browser storage vars keep their storage settings when a factory is assigned, and declared factories are honored when compiled and reset.
1+
Defaults are no longer part of the saved-state schema, so changing one keeps state saved by this release or later loadable. A browser storage var annotated with a storage type, such as `rx.Field[rx.LocalStorage]`, and declared with a `default_factory` compiles with the storage options the factory produces, and `reset()` restores the factory's value.

‎news/+state-default-patch-restore.bugfix.md‎

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Assigning a state var through its state class, such as `State.count = 10`, raises `TypeError` instead of replacing the var; so do pytest's `monkeypatch.setattr` and `unittest.mock.patch.object` on a var. Change a default with `State.__fields__["count"].default = 10`, patch the field in tests, and declare class-level configuration as `ClassVar`.

‎news/+storage-default-assignment.bugfix.md‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎packages/reflex-base/news/+backend-class-assignment.bugfix.md‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎packages/reflex-base/news/+state-default-patch-restore.bugfix.md‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

0 commit comments

Comments
 (0)