Add Field.set_default and point the state var assignment error at the declaring state

Merged
MA
25d ago
+254-49

Follow-up to https://github.com/reflex-dev/reflex/pull/7516. It addresses @FarhanAliRaza's two post-merge review comments on the TypeError raised when a state var is assigned through its class.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Changes

Field.set_default(default=MISSING, *, default_factory=None) (comment). This is one supported way to change a state var's default. It takes exactly one of default (positional or keyword) or default_factory (keyword-only), and raises TypeError if it gets neither or both. As in Field and _default_arguments, a None factory means no factory.

  • An immutable default is stored as is.
  • A mutable default becomes a factory that returns a copy of the value as it was when set (it goes through _default_arguments, which Field._with_default also uses).
  • A default_factory replaces any default that is set.

Either way, default and default_factory stay mutually exclusive, so a stale default can no longer silently win over a new factory. Sessions never share a mutable default.

_default_arguments now copies a mutable value once up front. Instances read the default lazily, so without that copy, a caller who changed their list after set_default would change the default too. The same protection now applies to State.add_var, and a mutable class-declared default is deep-copied once when its class is created. Mutability is checked with types.is_immutable on the value itself only, not on items nested inside a tuple.

WatchlistState.__fields__["ticker"].set_default("MSFT")
WatchlistState.__fields__["symbols"].set_default(["AAPL", "MSFT"])
WatchlistState.__fields__["opened_at"].set_default(default_factory=time.time)

The error names the state that declares the var (comment). The message now names the field's _owner and points at the owner's field. When you assign through a substate, it also says the change applies to every state that inherits the var. Because set_default handles every kind of value, the suggestion is the same for all of them:

'count' is a state var of Parent, inherited by Child; assigning it on Child would replace the var.
Set its default with Parent.__fields__['count'].set_default(...), which applies to every state that
inherits it, or declare class-level config as ClassVar.

del Child.count (and the cleanup of a refused mock.patch.object) gets the same attribution. A state that mixes in a mixin's var owns its own copy of the field, so the error names that state, not the mixin. A substate of it names it as the declaring state. Only an assignment on the mixin class itself names the mixin.

Docs and tests now use set_default instead of assigning .default or .default_factory:

  • Docs: Changing Defaults, ComponentState, and the 0.10 upgrade guide.
  • Tests: ComponentState unit and integration tests, and the storage hydration test.

The upgrade guide also notes a 0.9 trap. On 0.9, assigning a mutable .default was safe because each instance got a copy. In 0.10 every instance shares the field's default, so a mutable value assigned there leaks changes between sessions.

Tests

  • test_field_set_default covers:
    • an immutable default, passed positionally and by keyword;
    • a mutable default replacing a set default with a copying factory, with no sharing between instances or with the caller;
    • an immutable default replacing a factory;
    • a default_factory replacing a default and being called for each instance;
    • None still working as a default.
  • test_field_set_default_takes_exactly_one covers the neither, explicit default_factory=None, and both cases.
  • The error tests (test_class_assignment_error_names_the_declaring_state, ..._names_the_state_mixing_in_a_var, ..._suggests_set_default) check the owner attribution and the uniform set_default(...) suggestion.
  • uv run pytest tests/units passes except tests/units/reflex_cli and tests/units/reflex_bench/drivers/test_browser.py, which fail the same way on main in my environment.
  • tests/integration/tests_playwright/test_hydration_storage.py passes (32 tests).
  • I couldn't run the Selenium tests/integration/test_component_state.py locally, because the container can't download a ChromeDriver.
  • ruff check, ruff format, and uv run pyright reflex tests are clean.

šŸ¤– Generated with Claude Code

https://claude.ai/code/session_014GtHWzR4ASUdN2JLmy6ycf