Skip to content

Back out class-level default assignment; refuse to assign over a state var - #7516

Merged
masenf merged 8 commits into
mainfrom
claude/back-out-class-defaults
Oct 8, 2026
Merged

masenf merged 8 commits into
mainfrom
claude/back-out-class-defaults

Conversation

@masenf

@masenf masenf commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

What and why

#7461 made State.var = value update the var's default instead of replacing its Field descriptor. #7495 then added patch/restore history and kept browser storage on plain assignments, and #7512 was fixing the remaining patch edge cases. Per the review on #7512, that layer on top of the Field descriptor is not ready for 0.10, so it comes out of the release.

Reverting #7461 outright would bring back the 0.10.0a1 behavior: assignment replaced the descriptor, which broke the var (a backend var read the value only until pickled, then None; dev writes and reset() raised). Instead, the metaclass now refuses an assignment over a state var:

State._x = 5
# TypeError: '_x' is a state var of State; assigning it on the class would replace
# the var. Set its default with State.__fields__['_x'].default = ..., or declare
# class-level config as ClassVar.
  • BaseStateMeta.__setattr__ raises TypeError when the name resolves, through the MRO, to a state var's Field, including a var inherited by a substate. Other names are unaffected: new names, ClassVars (including one redeclaring an inherited var), handlers and setters. A name in cls.__fields__ check skips the MRO walk for them.
  • Assigning a var's own field back is a no-op. That keeps the cleanup of a failed mock.patch.object quiet.
  • del State.x still removes the descriptor. Deleting first, then setting, replaces the var on purpose.
  • add_var/add_field bind through the guard, because add_var rejects any name already in __fields__. _bind_attr keeps the type.__setattr__ it had since Preserve state descriptors when assigning class defaults #7461: a field copied from a mixin replaces the name that resolves to the mixin's field.

Removed:

  • Field._assign_default / _restore_default / _keep_default and the replaced-default history.
  • _accepts_default, _keep_client_storage, _assigned_field, Field._client_storage and ClientStorageBase._with_value.
  • The metaclass __delattr__.
  • Their tests and news fragments.

Kept (independent of the assignment layer):

Setting defaults now goes through the field. The ComponentState docs go back to the pre-#7461 pattern:

cls.__fields__["text"].default = initial_value
cls.__fields__["items"].default_factory = lambda: [label]
cls.__fields__["theme"].default = rx.LocalStorage(initial, name=f"theme_{key}")

Each generated ComponentState class owns its field copies (the mixin copy, independent of #7461), so this is per component. A browser storage var stays in storage only with a storage value as its default. The docs now say so, and fold in #7514's note about the key shared by a named storage var.

Tests patch the field:

with mock.patch.object(State.__fields__["_limit"], "default", 99):
    ...

monkeypatch.setattr(State, "_limit", v) and mock.patch.object(State, "_limit", v) assign straight over the attribute, so they raise now. monkeypatch.delattr(State, "_limit") followed by monkeypatch.setattr(State, "_limit", v, raising=False) round-trips.

Compatibility

0.9.12 accepted State.var = value, but new instances still started at the declared default. On 0.10 the assignment raises. The upgrade guide's section and the breaking fragments give the migration: set the default on the field, patch the field in tests, and use ClassVar for class-level configuration. A ClassVar behaves as on 0.9.

Tests

  • New unit tests (tests/units/reflex_base/vars/test_base.py):
    • assignment over a frontend or backend var raises on the declaring state and on a substate, and leaves the field in place;
    • ClassVars, including one over an inherited var, and new names stay assignable;
    • patching the field's default round-trips, and so does monkeypatch.delattr + setattr;
    • mock.patch.object on the class attribute raises, and its cleanup leaves the var intact.
  • Converted to __fields__:
    • the per-component default test (now also checks a per-component storage key);
    • the schema test;
    • the integration ComponentState app;
    • the hydration-storage /assigned page.
  • Removed: the class-assignment, patch-history and storage-collapse tests.
  • Results:
    • the unit suite matches main's results in this sandbox: the only failures are the same 236 reflex_cli/v2 and reflex_bench browser tests that fail identically on main here;
    • Playwright test_hydration_storage.py and test_component_state.py pass in dev and prod (12/12);
    • Selenium test_client_storage.py passes. test_component_state.py::test_component_state_app fails here, but it fails the same way on the pre-change code, so it's an environment issue;
    • pyright reflex tests: 0 errors;
    • pre-commit clean.

Supersedes #7512 (patch-undo fixes for the removed layer) and #7514 (docs for the assignment pattern).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo


Generated by Claude Code

View guided diff Turn on auto-fix

…e 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

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 21 files

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/state_structure/component_state.md Resolved
Comment thread docs/vars/base_vars.md Resolved
claude added 2 commits October 8, 2026 07:26
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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 2 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/vars/base_vars.md Resolved
@greptile-apps

greptile-apps Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

[High risk] Changes how state var defaults are assigned and modified.

The PR appears safe to merge, with both earlier concerns addressed and no new actionable issues.

What we checked:

  • Sharing values across workers: Without Redis, normal startup uses one worker. With Redis configured, the default state manager uses Redis.

Summary

This PR rejects class assignments that would replace a state var and moves default changes to the var's field.

  • Removes default-assignment history and updates tests and migration docs.
  • The latest changes clarify that ClassVar values belong to each worker.
  • Both earlier review concerns are addressed: inherited patch cleanup now raises TypeError, and the storage warning distinguishes plain-string annotations from storage annotations.
  • No new actionable issues or rule violations were found.

Reviews (3) · Last reviewed commit: "Point cross-session runtime values to Sh..." · Reviewed by Greptile

Comment thread docs/vars/base_vars.md Resolved
@codspeed

codspeed Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by 5.37%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 149 untouched benchmarks
⏩ 18 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_hydration_snapshot[200] 8.9 ms 8.4 ms +5.37%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/back-out-class-defaults (b51406e) with main (03a2e95)2

Open in CodSpeed

Footnotes

  1. 18 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (9b81d8c) during the generation of this report, so 03a2e95 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

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
@masenf

masenf commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

The CodSpeed failure here isn't caused by this PR. It flags only test_hydration_metadata[200] (-7% on 72231b8, -18% on the superseded 9c2e48e), compared across different runtime environments. That benchmark has flagged the same way on unrelated PRs:

#7503 drops the benchmark as flaky. This PR's code change is the metaclass __setattr__/__delattr__ guard, which only runs when an attribute is set or deleted on a state class. A hydration benchmark doesn't do that. The guard also skips its MRO lookup for any name that isn't a declared var.

CodSpeed isn't a required check, so I'm not widening this PR to remove the benchmark.


Generated by Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/vars/base_vars.md Resolved
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

@masenf masenf left a comment

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.

👍 remove dodgy default handling code in BaseState meta.

we can make an explicit API for setting runtime defaults that doesn't have so many edge cases.

fix the underlying reason for going down this road: protect unexpected assignments over Field descriptors that now actually affect how the state is rendered and used.

(current 0.9.12 accounts for vars internally and isn't completely broken by overwriting class attributes, but also not having any useful functionality)

Comment thread docs/vars/base_vars.md Resolved
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

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/vars/base_vars.md Resolved
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
@masenf
masenf merged commit 064ba67 into main Oct 8, 2026
146 checks passed
@masenf
masenf deleted the claude/back-out-class-defaults branch October 8, 2026 14:31
masenf pushed a commit that referenced this pull request Oct 8, 2026
Record the class-default back-out as merged (064ba67); #7507 and
#7511 are now moot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo
masenf pushed a commit that referenced this pull request Oct 8, 2026
…9583012

Reconcile fragments main changed after the train materialized them: carry
the #7513 wording of the #7312 breaking entry and the #7516 wording of the
#7461 entry into the a2/a3 changelogs, and drop the #7495 patch-restore and
storage-default entries that #7516 backed out.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWWGUbPYwC5ELRyhCjm5ym
return TypeError(
f"{name!r} is a state var of {cls.__name__}; {action} it on the class "
f"would replace the var. Set its default with "
f"{cls.__name__}.__fields__[{name!r}].default = ..., or declare "

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.

With a mutable value, the .default = ... fix this message suggests shares one object across every session, and in-place edits corrupt it for later sessions and for reset().

Field.default_value() returns self.default without copying it. Before this PR, the class-assignment path sent the value through _default_arguments, which turns a mutable value into a deep-copy default_factory. On 0.9.12 the same __fields__[...].default idiom was safe because state.py deep-copied field.default on init and on reset. Users who bring that 0.9 idiom forward (the ComponentState docs used it) will now leak data between sessions.

Repro:

class S(rx.State):
    items: list[str] = []

S.__fields__["items"].default = ["a"]
a, b = S(_reflex_internal_init=True), S(_reflex_internal_init=True)
a.items.append("leak")
b.items      # ['a', 'leak']
a.reset()
a.items      # ['a', 'leak'] -- the default itself was mutated

base_vars.md does say to use default_factory for mutable defaults, but this error message and the cls.__fields__[...].default = props.pop(...) pattern in get_component lead straight to the shared value. Two possible fixes:

  • Add a small supported setter that normalizes through _default_arguments(value), as Field._with_default already does, and point this message and the docs at it.
  • Have default_value() copy a default that types.is_immutable rejects.

Generated by Claude Code

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.

Addressed in #7519. For a mutable value, the error now suggests .default_factory = lambda: ... instead of .default = .... If the field already has a default set, it also says to set .default = dataclasses.MISSING first, since a set default wins over the factory. That PR only changes the message: it adds no setter and doesn't make default_value() copy.


Generated by Claude Code

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.

Update: #7519 now takes your first option. It adds Field.set_default(value), which goes through _default_arguments, so a mutable value becomes a deep-copying factory and default/default_factory stay mutually exclusive. The error now suggests Owner.__fields__[name].set_default(...) for every value. The docs, including the get_component examples, use it too, and the upgrade guide calls out the 0.9 .default = [...] trap.


Generated by Claude Code

return None
return declared
return TypeError(
f"{name!r} is a state var of {cls.__name__}; {action} it on the class "

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.

When the assignment goes through a substate, this message names the substate and suggests a fix that silently changes the parent's default and every sibling's.

class Parent(rx.State):
    count: int = 0

class Child(Parent):
    pass

Child.count = 9
# TypeError: 'count' is a state var of Child; ... Set its default with
# Child.__fields__['count'].default = ...
Child.__fields__["count"].default = 9
Parent.__fields__["count"].default   # 9: the Field is shared

base_vars.md documents this behavior, but the message reads as if count belongs to Child and the change will be local to it. Consider naming the declaring class (the field's _owner) in both places, and saying that the change applies to every state that inherits the var when cls isn't the owner.


Generated by Claude Code

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.

Addressed in #7519. The error now names the field's _owner and points at Parent.__fields__[...]. When you assign through a substate, it reads "'count' is a state var of Parent, inherited by Child; assigning it on Child ..." and says the change "applies to every state that inherits it". del Child.count names the owner the same way.


Generated by Claude Code

masenf pushed a commit that referenced this pull request Oct 8, 2026
…9583012

Reconcile fragments main changed after the train materialized them:
carry #7519's `set_default` wording of the #7516 breaking entries into
the reflex and reflex-base a4 changelogs, and keep both materialized
fragments deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo
masenf pushed a commit that referenced this pull request Oct 9, 2026
Curated from the a2-a5 passes, deduplicated and parameterized; the
Python suite, pytest suites, N-004 interchange and the cse2e app were
re-run on the released 0.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SjCrbdguMkfe4bhbaREiwo
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants