From 6c774e69208f0eb484cb5063ecd7390412b3dd5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:10:08 +0000 Subject: [PATCH 1/6] Reject browser storage defaults produced by a default_factory A browser storage var is classified at compile time from its default value or its annotation. A default_factory only reveals what it produces when called, so a str-annotated var whose factory returns a LocalStorage was never compiled as storage and silently held a storage instance as an ordinary str (#7498), while a storage-annotated var's factory was called during compile_client_storage just to read its options. compile_client_storage now raises VarTypeError in both cases: a storage annotated field with a factory (declared, or set later through set_default), and a str/Any-compatible field whose factory returns a storage value. The factory of a str-compatible field is called once at compile for the check; factories for other types are never called. The special case reading options from a factory's product is removed, and the tests that exercised factory-declared storage go with it. Closes #7498 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uPh1ADyPWucA2MM6zpW9T --- docs/api-reference/browser_storage.md | 11 ++ docs/vars/base_vars.md | 4 +- ...eject-factory-storage-defaults.breaking.md | 1 + reflex/compiler/utils.py | 66 ++++++++-- .../test_hydration_storage.py | 10 +- tests/units/compiler/test_compiler_utils.py | 114 +++++++++++++++--- tests/units/test_state.py | 41 ------- 7 files changed, 169 insertions(+), 78 deletions(-) create mode 100644 news/+reject-factory-storage-defaults.breaking.md diff --git a/docs/api-reference/browser_storage.md b/docs/api-reference/browser_storage.md index 59834ea3ed3..2363fb142d8 100644 --- a/docs/api-reference/browser_storage.md +++ b/docs/api-reference/browser_storage.md @@ -34,6 +34,17 @@ cannot be passed as a keyword argument — the keyword arguments (`name`, `max_age`, `sync`, etc.) only configure the storage behavior. ``` +```md alert info +# Declare browser storage as a default value or a storage annotation. + +A var is stored in the browser when its default is a storage value, such as +`theme: str = rx.LocalStorage("light", name="theme")`, or when it is annotated +with a storage type, such as `theme: rx.LocalStorage = "light"`. The storage key +and options must be known when the app compiles, so a `default_factory` cannot +provide them: `rx.field(default_factory=lambda: rx.LocalStorage(...))` fails to +compile, whichever way the var is annotated. +``` + ```md alert warning # **The default value of a Cookie is never set in the browser!** diff --git a/docs/vars/base_vars.md b/docs/vars/base_vars.md index 8dc8b0adde2..0152a187779 100644 --- a/docs/vars/base_vars.md +++ b/docs/vars/base_vars.md @@ -106,7 +106,9 @@ storage var keeps its storage name and options only with a storage value as its default, such as `rx.LocalStorage("dark", name="theme")`. Given a plain string, a var annotated `str` becomes an ordinary var, and one annotated with a storage type, such as `rx.LocalStorage`, stays in browser storage under the default key -and options. +and options. A `default_factory` cannot declare browser storage: the app fails +to compile when a storage var has a factory, or when a factory returns a storage +value. See [Browser Storage](/docs/api-reference/browser-storage/). An inherited var belongs to the state that declared it, so changing its field also changes the default for every state that inherits it. Each generated diff --git a/news/+reject-factory-storage-defaults.breaking.md b/news/+reject-factory-storage-defaults.breaking.md new file mode 100644 index 00000000000..cb5a06caa20 --- /dev/null +++ b/news/+reject-factory-storage-defaults.breaking.md @@ -0,0 +1 @@ +Browser storage vars (`rx.Cookie`, `rx.LocalStorage`, `rx.SessionStorage`) must be declared with a storage value as the default or with a storage type annotation. A `default_factory` on a storage var, or one returning a storage value, now fails at compile time instead of being silently ignored or treated as an ordinary var. diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 8168fed9668..36048369eb9 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -11,9 +11,10 @@ import tempfile import traceback from collections.abc import Iterable, Mapping, Sequence +from dataclasses import MISSING from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict, get_args from urllib.parse import urlparse from reflex_base import constants @@ -28,7 +29,8 @@ from reflex_base.constants.state import FIELD_MARKER from reflex_base.registry import RegistrationContext from reflex_base.style import Style -from reflex_base.utils import format, imports, memo_paths, serializers +from reflex_base.utils import format, imports, memo_paths, serializers, types +from reflex_base.utils.exceptions import VarTypeError from reflex_base.utils.imports import ImportVar, ParsedImportDict from reflex_base.vars.base import Field, Var, VarData from reflex_base.vars.function import DestructuredArg @@ -40,7 +42,12 @@ from reflex_components_core.el.elements.sectioning import Body from reflex.istate.delta import _resolve_delta -from reflex.istate.storage import Cookie, LocalStorage, SessionStorage +from reflex.istate.storage import ( + ClientStorageBase, + Cookie, + LocalStorage, + SessionStorage, +) from reflex.state import BaseState, cache_initial_snapshot from reflex.utils import path_ops from reflex.utils.exec import is_prod_mode @@ -326,17 +333,60 @@ def _compile_client_storage_field( if isinstance(field.default, field_type): cs_obj = field.default elif isinstance(field.type_, type) and issubclass(field.type_, field_type): - # A factory-backed field carries its options in what it produces. - produced = ( - field.default_factory() if field.default_factory is not None else None - ) - cs_obj = produced if isinstance(produced, field_type) else field.type_() + if field.default is MISSING: + _raise_factory_storage(field, field.type_) + cs_obj = field.type_() else: continue return field_type, cs_obj.options() + if ( + field.default is MISSING + and field.default_factory is not None + and _accepts_str(field.outer_type_) + and isinstance(produced := field.default_factory(), ClientStorageBase) + ): + _raise_factory_storage(field, type(produced)) return None, None +def _accepts_str(type_: Any) -> bool: + """Whether a value of the type can be a browser storage value, a str subclass. + + Args: + type_: The field's annotated type. + + Returns: + Whether the type accepts a str. + """ + if type_ is Any: + return True + if types.is_union(type_): + return any(_accepts_str(arg) for arg in get_args(type_)) + return isinstance(type_, type) and issubclass(str, type_) + + +def _raise_factory_storage(field: Field, storage_type: type) -> None: + """Reject a browser storage default produced by a default_factory. + + The storage name and options must be known when the app compiles, so a + storage var declares them with a storage value as its default. + + Args: + field: The field with a default_factory. + storage_type: The browser storage type the field declares or produces. + + Raises: + VarTypeError: Always. + """ + owner = field._owner.__name__ if field._owner is not None else "" + msg = ( + f"Browser storage var '{owner}.{field._name}' cannot use a default_factory." + " Declare the storage as the default value instead, such as" + f' `{field._name}: str = rx.{storage_type.__name__}("", name={field._name!r})`.' + ) + raise VarTypeError(msg) + + def _compile_client_storage_recursive( state: type[BaseState], ) -> tuple[ diff --git a/tests/integration/tests_playwright/test_hydration_storage.py b/tests/integration/tests_playwright/test_hydration_storage.py index 0073896411d..17d43c330fa 100644 --- a/tests/integration/tests_playwright/test_hydration_storage.py +++ b/tests/integration/tests_playwright/test_hydration_storage.py @@ -61,9 +61,6 @@ class AssignedStorageState(rx.State): local: str = rx.LocalStorage("declared", name="assigned-local") session: str = rx.SessionStorage("declared", name="assigned-session") cookie: str = rx.Cookie("declared", name="assigned-cookie") - factory: rx.Field[str] = rx.field( - default_factory=lambda: rx.LocalStorage("declared", name="assigned-factory") - ) @rx.event def change(self): @@ -71,7 +68,6 @@ def change(self): self.local = "changed" self.session = "changed" self.cookie = "changed" - self.factory = "changed" fields = AssignedStorageState.__fields__ fields["local"].set_default(rx.LocalStorage("assigned", name="assigned-local")) @@ -79,8 +75,6 @@ def change(self): rx.SessionStorage("assigned", name="assigned-session") ) fields["cookie"].set_default(rx.Cookie("assigned", name="assigned-cookie")) - # A storage value as the default replaces the declared factory. - fields["factory"].set_default(rx.LocalStorage("assigned", name="assigned-factory")) class PreferenceState(rx.ComponentState): pref: str = rx.LocalStorage("declared", name="assigned-pref") @@ -202,7 +196,6 @@ def assigned(): rx.text(AssignedStorageState.local, id="assigned-local"), rx.text(AssignedStorageState.session, id="assigned-session"), rx.text(AssignedStorageState.cookie, id="assigned-cookie"), - rx.text(AssignedStorageState.factory, id="assigned-factory"), rx.button("Change", on_click=AssignedStorageState.change, id="change"), PreferenceState.create(initial="assigned"), rx.text(rx.cond(rx.State.is_hydrated, "true", "false"), id="hydrated"), @@ -298,7 +291,7 @@ def test_hydration_reconciles_storage_in_get_delta( # The browser storage vars of the assigned page, by element id suffix. -VARS = ("local", "session", "cookie", "factory", "pref") +VARS = ("local", "session", "cookie", "pref") def test_assigned_storage_default_persists( @@ -323,7 +316,6 @@ def test_assigned_storage_default_persists( localStorage.getItem('assigned-local') === 'changed' && sessionStorage.getItem('assigned-session') === 'changed' && localStorage.getItem('assigned-pref') === 'changed' && - localStorage.getItem('assigned-factory') === 'changed' && document.cookie.split('; ').includes('assigned-cookie=changed') """) diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index b92647a9677..856b57959d6 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -18,6 +18,7 @@ from reflex.compiler.utils import write_file as compiler_write_file from reflex.constants.state import FIELD_MARKER from reflex.state import State, state_snapshot_hashes +from reflex.utils.exceptions import VarTypeError from reflex.utils.path_ops import write_file from reflex.vars.base import computed_var @@ -232,37 +233,112 @@ class MixedKeyState(State): assert len(state_snapshot_hashes(compiled)) == len(compiled) + 1 -def test_compile_client_storage_honors_default_factories( +@pytest.mark.parametrize( + "annotation", + [rx.Cookie, rx.LocalStorage, rx.SessionStorage], + ids=lambda t: t.__name__, +) +def test_compile_client_storage_rejects_factory_on_storage_annotation( + annotation: type, + forked_registration_context: RegistrationContext, +): + """A browser storage var annotated with a storage type takes no default_factory. + + Args: + annotation: The browser storage type the var is annotated with. + forked_registration_context: Keeps the test's state out of other tests. + """ + # Built with type() so the annotation is the parametrized storage type. + StorageState = type( + "StorageState", + (State,), + { + "__module__": __name__, + "__qualname__": "test_rejects_factory_on_storage_annotation..StorageState", + "__annotations__": {"value": annotation}, + "value": rx.field(default_factory=lambda: annotation("new", name="key")), + }, + ) + + with pytest.raises(VarTypeError, match=r"\.value' cannot use a default_factory"): + utils.compile_client_storage(StorageState) + + +def test_compile_client_storage_rejects_factory_on_set_default( forked_registration_context: RegistrationContext, ): - """Factory-backed browser storage fields compile with the options they produce. + """set_default with a default_factory is rejected on a browser storage var. Args: forked_registration_context: Keeps the test's state out of other tests. """ class StorageState(State): - cookie: rx.Field[rx.Cookie] = rx.field( - default_factory=lambda: rx.Cookie("new", name="cookie-key", max_age=60) - ) - local: rx.Field[rx.LocalStorage] = rx.field( - default_factory=lambda: rx.LocalStorage("new", name="local-key", sync=True) + value: rx.LocalStorage = rx.LocalStorage("declared", name="storage-key") + + StorageState.__fields__["value"].set_default( + default_factory=lambda: rx.LocalStorage("new", name="storage-key") + ) + with pytest.raises(VarTypeError, match=r"\.value' cannot use a default_factory"): + utils.compile_client_storage(StorageState) + + +def test_compile_client_storage_rejects_factory_producing_storage( + forked_registration_context: RegistrationContext, +): + """A plain-annotated var whose default_factory produces browser storage is rejected. + + Args: + forked_registration_context: Keeps the test's state out of other tests. + """ + + class StorageState(State): + theme: rx.Field[str] = rx.field( + default_factory=lambda: rx.LocalStorage("light", name="theme") ) - session: rx.Field[rx.SessionStorage] = rx.field( - default_factory=lambda: rx.SessionStorage("new", name="session-key") + + class OptionalStorageState(State): + theme: rx.Field[str | None] = rx.field( + default_factory=lambda: rx.LocalStorage("light", name="theme") ) - compiled = utils.compile_client_storage(StorageState) - name = StorageState.get_full_name() - cookie = compiled[constants.COOKIES][f"{name}.cookie{FIELD_MARKER}"] - assert (cookie["name"], cookie["maxAge"]) == ("cookie-key", 60) - assert compiled[constants.LOCAL_STORAGE][f"{name}.local{FIELD_MARKER}"] == { - "name": "local-key", - "sync": True, - } - assert compiled[constants.SESSION_STORAGE][f"{name}.session{FIELD_MARKER}"] == { - "name": "session-key" + with pytest.raises(VarTypeError, match=r"\.theme' cannot use a default_factory"): + utils.compile_client_storage(StorageState) + with pytest.raises(VarTypeError, match=r"\.theme' cannot use a default_factory"): + utils.compile_client_storage(OptionalStorageState) + + +def test_compile_client_storage_skips_plain_factories( + forked_registration_context: RegistrationContext, +): + """A default_factory producing a plain value compiles as an ordinary var. + + Args: + forked_registration_context: Keeps the test's state out of other tests. + """ + calls = [] + + def make_text() -> str: + """Record the call and return a plain default. + + Returns: + The default text. + """ + calls.append(1) + return "plain" + + class PlainState(State): + text: rx.Field[str] = rx.field(default_factory=make_text) + items: rx.Field[list[str]] = rx.field(default_factory=list) + local: str = rx.LocalStorage("declared", name="local-key") + + compiled = utils.compile_client_storage(PlainState) + name = PlainState.get_full_name() + assert compiled[constants.LOCAL_STORAGE] == { + f"{name}.local{FIELD_MARKER}": {"name": "local-key", "sync": False} } + assert compiled[constants.COOKIES] == compiled[constants.SESSION_STORAGE] == {} + assert calls == [1] def test_document_root_allows_static_id_on_head_script(): diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 5f6fd7cceff..bab25edd013 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -5908,47 +5908,6 @@ def test_frontend_schema_leaves_out_backend_vars(): ) -def test_reset_client_storage_uses_declared_factories(): - """Hydration evaluates declared factories for all browser storage types.""" - calls = [] - - def factory(storage_type: type) -> Any: - """Record calls and return the configured browser default. - - Args: - storage_type: The browser storage value type. - - Returns: - The new storage value. - """ - calls.append(storage_type) - return storage_type("new") - - class StorageState(BaseState): - cookie: rx.Field[rx.Cookie] = rx.field( - default_factory=lambda: factory(rx.Cookie) - ) - local: rx.Field[rx.LocalStorage] = rx.field( - default_factory=lambda: factory(rx.LocalStorage) - ) - session: rx.Field[rx.SessionStorage] = rx.field( - default_factory=lambda: factory(rx.SessionStorage) - ) - - assert calls == [] - state = StorageState( - cookie=rx.Cookie("saved"), - local=rx.LocalStorage("saved"), - session=rx.SessionStorage("saved"), - ) - state._reset_client_storage() - assert state.cookie == state.local == state.session == "new" - assert calls == [rx.Cookie, rx.LocalStorage, rx.SessionStorage] - state._reset_client_storage() - assert state.cookie == state.local == state.session == "new" - assert calls == [rx.Cookie, rx.LocalStorage, rx.SessionStorage] * 2 - - @pytest.mark.parametrize( "breakage", [ From 2df0ee7dc37bcb662180cf156c3ece663595ef63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:15:07 +0000 Subject: [PATCH 2/6] Skip factory check for types issubclass cannot inspect _accepts_str used issubclass(str, type_) directly, which raises for a TypedDict annotation (and other types that refuse class checks), so compiling an app with a TypedDict factory field failed. Use safe_issubclass, which treats such types as not accepting a str. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uPh1ADyPWucA2MM6zpW9T --- reflex/compiler/utils.py | 2 +- tests/units/compiler/test_compiler_utils.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 36048369eb9..6b4166f276f 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -362,7 +362,7 @@ def _accepts_str(type_: Any) -> bool: return True if types.is_union(type_): return any(_accepts_str(arg) for arg in get_args(type_)) - return isinstance(type_, type) and issubclass(str, type_) + return types.safe_issubclass(str, type_) def _raise_factory_storage(field: Field, storage_type: type) -> None: diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index 856b57959d6..a99690f5f62 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy +from typing import TypedDict import pytest from reflex_base import constants @@ -308,6 +309,12 @@ class OptionalStorageState(State): utils.compile_client_storage(OptionalStorageState) +class Node(TypedDict): + """A typed dict default, which issubclass refuses to check.""" + + name: str + + def test_compile_client_storage_skips_plain_factories( forked_registration_context: RegistrationContext, ): @@ -330,6 +337,8 @@ def make_text() -> str: class PlainState(State): text: rx.Field[str] = rx.field(default_factory=make_text) items: rx.Field[list[str]] = rx.field(default_factory=list) + # issubclass against a TypedDict raises, so the check must not ask it. + node: rx.Field[Node] = rx.field(default_factory=lambda: Node(name="root")) local: str = rx.LocalStorage("declared", name="local-key") compiled = utils.compile_client_storage(PlainState) From 248a4840bf4af23a31bf0ddfa1efd6dbdf77c6cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 17:17:01 +0000 Subject: [PATCH 3/6] Leave backend factories alone; catch storage types inside unions The factory check only concerns frontend vars, since a backend var is never browser storage, and a backend factory may depend on runtime-only state such as an environment variable. A union annotation now counts as holding a str when any member is a str or a str subclass, so a storage type inside an Optional is checked too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uPh1ADyPWucA2MM6zpW9T --- reflex/compiler/utils.py | 13 +++++------ tests/units/compiler/test_compiler_utils.py | 24 +++++++++++++++++---- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 6b4166f276f..7b4a209a439 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -340,29 +340,30 @@ def _compile_client_storage_field( continue return field_type, cs_obj.options() if ( - field.default is MISSING + not field._backend + and field.default is MISSING and field.default_factory is not None - and _accepts_str(field.outer_type_) + and _may_hold_str(field.outer_type_) and isinstance(produced := field.default_factory(), ClientStorageBase) ): _raise_factory_storage(field, type(produced)) return None, None -def _accepts_str(type_: Any) -> bool: +def _may_hold_str(type_: Any) -> bool: """Whether a value of the type can be a browser storage value, a str subclass. Args: type_: The field's annotated type. Returns: - Whether the type accepts a str. + Whether the type accepts a str or a str subclass. """ if type_ is Any: return True if types.is_union(type_): - return any(_accepts_str(arg) for arg in get_args(type_)) - return types.safe_issubclass(str, type_) + return any(_may_hold_str(arg) for arg in get_args(type_)) + return types.safe_issubclass(str, type_) or types.safe_issubclass(type_, str) def _raise_factory_storage(field: Field, storage_type: type) -> None: diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index a99690f5f62..22e876aeddd 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -303,10 +303,16 @@ class OptionalStorageState(State): default_factory=lambda: rx.LocalStorage("light", name="theme") ) - with pytest.raises(VarTypeError, match=r"\.theme' cannot use a default_factory"): - utils.compile_client_storage(StorageState) - with pytest.raises(VarTypeError, match=r"\.theme' cannot use a default_factory"): - utils.compile_client_storage(OptionalStorageState) + class OptionalAnnotatedStorageState(State): + theme: rx.Field[rx.LocalStorage | None] = rx.field( + default_factory=lambda: rx.LocalStorage("light", name="theme") + ) + + for state in (StorageState, OptionalStorageState, OptionalAnnotatedStorageState): + with pytest.raises( + VarTypeError, match=r"\.theme' cannot use a default_factory" + ): + utils.compile_client_storage(state) class Node(TypedDict): @@ -334,8 +340,18 @@ def make_text() -> str: calls.append(1) return "plain" + def missing_at_compile() -> str: + """Fail: a backend factory is never called while compiling. + + Raises: + RuntimeError: Always. + """ + msg = "only available at runtime" + raise RuntimeError(msg) + class PlainState(State): text: rx.Field[str] = rx.field(default_factory=make_text) + _token: rx.Field[str] = rx.field(default_factory=missing_at_compile) items: rx.Field[list[str]] = rx.field(default_factory=list) # issubclass against a TypedDict raises, so the check must not ask it. node: rx.Field[Node] = rx.field(default_factory=lambda: Node(name="root")) From f95144274097869df85ac61601290249f1af3fcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:55:13 +0000 Subject: [PATCH 4/6] Count a str Literal annotation as holding a str A Literal of string values accepts a str subclass at runtime, so a factory on such a field is checked for a storage value like a str field. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uPh1ADyPWucA2MM6zpW9T --- reflex/compiler/utils.py | 4 +++- tests/units/compiler/test_compiler_utils.py | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 7b4a209a439..873f36e93a2 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -14,7 +14,7 @@ from dataclasses import MISSING from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, TypedDict, get_args +from typing import TYPE_CHECKING, Any, Literal, TypedDict, get_args, get_origin from urllib.parse import urlparse from reflex_base import constants @@ -363,6 +363,8 @@ def _may_hold_str(type_: Any) -> bool: return True if types.is_union(type_): return any(_may_hold_str(arg) for arg in get_args(type_)) + if get_origin(type_) is Literal: + return any(isinstance(arg, str) for arg in get_args(type_)) return types.safe_issubclass(str, type_) or types.safe_issubclass(type_, str) diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index 22e876aeddd..ec77713707b 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -2,7 +2,7 @@ import asyncio from copy import deepcopy -from typing import TypedDict +from typing import Literal, TypedDict import pytest from reflex_base import constants @@ -308,7 +308,17 @@ class OptionalAnnotatedStorageState(State): default_factory=lambda: rx.LocalStorage("light", name="theme") ) - for state in (StorageState, OptionalStorageState, OptionalAnnotatedStorageState): + class LiteralStorageState(State): + theme: rx.Field[Literal["light", "dark"]] = rx.field( # pyright: ignore[reportAssignmentType] + default_factory=lambda: rx.LocalStorage("light", name="theme") + ) + + for state in ( + StorageState, + OptionalStorageState, + OptionalAnnotatedStorageState, + LiteralStorageState, + ): with pytest.raises( VarTypeError, match=r"\.theme' cannot use a default_factory" ): From 07d3be7d6d3b180961e379e2ed862a9aa1cebd92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:57:09 +0000 Subject: [PATCH 5/6] Unwrap Annotated in the factory check; narrow the docs wording An Annotated[str, ...] field is a str field, so its factory is checked for a storage value too. The docs now say which annotations the check covers instead of claiming it applies to every annotation. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uPh1ADyPWucA2MM6zpW9T --- docs/api-reference/browser_storage.md | 3 ++- docs/vars/base_vars.md | 5 +++-- reflex/compiler/utils.py | 1 + tests/units/compiler/test_compiler_utils.py | 8 +++++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/docs/api-reference/browser_storage.md b/docs/api-reference/browser_storage.md index 2363fb142d8..12d1fb26447 100644 --- a/docs/api-reference/browser_storage.md +++ b/docs/api-reference/browser_storage.md @@ -42,7 +42,8 @@ A var is stored in the browser when its default is a storage value, such as with a storage type, such as `theme: rx.LocalStorage = "light"`. The storage key and options must be known when the app compiles, so a `default_factory` cannot provide them: `rx.field(default_factory=lambda: rx.LocalStorage(...))` fails to -compile, whichever way the var is annotated. +compile on a var annotated with a storage type or with a type that accepts a +`str`, such as `str`, `str | None` or a `Literal` of strings. ``` ```md alert warning diff --git a/docs/vars/base_vars.md b/docs/vars/base_vars.md index 0152a187779..c8dd7f6478c 100644 --- a/docs/vars/base_vars.md +++ b/docs/vars/base_vars.md @@ -107,8 +107,9 @@ default, such as `rx.LocalStorage("dark", name="theme")`. Given a plain string, a var annotated `str` becomes an ordinary var, and one annotated with a storage type, such as `rx.LocalStorage`, stays in browser storage under the default key and options. A `default_factory` cannot declare browser storage: the app fails -to compile when a storage var has a factory, or when a factory returns a storage -value. See [Browser Storage](/docs/api-reference/browser-storage/). +to compile when a storage-typed var has a factory, or when the factory of a var +that accepts a `str` returns a storage value. See +[Browser Storage](/docs/api-reference/browser-storage/). An inherited var belongs to the state that declared it, so changing its field also changes the default for every state that inherits it. Each generated diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 873f36e93a2..f5d8d22105b 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -359,6 +359,7 @@ def _may_hold_str(type_: Any) -> bool: Returns: Whether the type accepts a str or a str subclass. """ + type_ = types.resolve_type_alias(type_) if type_ is Any: return True if types.is_union(type_): diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index ec77713707b..05d9640778f 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -2,7 +2,7 @@ import asyncio from copy import deepcopy -from typing import Literal, TypedDict +from typing import Annotated, Literal, TypedDict import pytest from reflex_base import constants @@ -313,11 +313,17 @@ class LiteralStorageState(State): default_factory=lambda: rx.LocalStorage("light", name="theme") ) + class MetadataStorageState(State): + theme: rx.Field[Annotated[str, "meta"]] = rx.field( + default_factory=lambda: rx.LocalStorage("light", name="theme") + ) + for state in ( StorageState, OptionalStorageState, OptionalAnnotatedStorageState, LiteralStorageState, + MetadataStorageState, ): with pytest.raises( VarTypeError, match=r"\.theme' cannot use a default_factory" From 00a256112fd0868c8904ad2fe3e9292832b82d87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:09:28 +0000 Subject: [PATCH 6/6] Skip factories that raise when checking for storage defaults The factory call in _compile_client_storage_field is only a check for a storage value hidden behind a default_factory. A frontend str factory that needs a runtime resource raised during compile and stopped reflex run and reflex export; such a factory declares no storage, so it is skipped and the var compiles as an ordinary var. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014uPh1ADyPWucA2MM6zpW9T --- reflex/compiler/utils.py | 9 +++++++-- tests/units/compiler/test_compiler_utils.py | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index f5d8d22105b..8490845954c 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -344,9 +344,14 @@ def _compile_client_storage_field( and field.default is MISSING and field.default_factory is not None and _may_hold_str(field.outer_type_) - and isinstance(produced := field.default_factory(), ClientStorageBase) ): - _raise_factory_storage(field, type(produced)) + # A factory that only works at runtime declares no storage. + try: + produced = field.default_factory() + except Exception: + return None, None + if isinstance(produced, ClientStorageBase): + _raise_factory_storage(field, type(produced)) return None, None diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index 05d9640778f..d136ed8ac8a 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -357,7 +357,7 @@ def make_text() -> str: return "plain" def missing_at_compile() -> str: - """Fail: a backend factory is never called while compiling. + """Fail: a factory that only works at runtime is skipped while compiling. Raises: RuntimeError: Always. @@ -368,6 +368,7 @@ def missing_at_compile() -> str: class PlainState(State): text: rx.Field[str] = rx.field(default_factory=make_text) _token: rx.Field[str] = rx.field(default_factory=missing_at_compile) + runtime_only: rx.Field[str] = rx.field(default_factory=missing_at_compile) items: rx.Field[list[str]] = rx.field(default_factory=list) # issubclass against a TypedDict raises, so the check must not ask it. node: rx.Field[Node] = rx.field(default_factory=lambda: Node(name="root"))