Explorar el Código

Merge branch 'main' into lendemor/builtins_states

Lendemor hace 6 meses
padre
commit
76dce8bba3
Se han modificado 41 ficheros con 466 adiciones y 189 borrados
  1. 1 0
      .github/workflows/integration_app_harness.yml
  2. 18 17
      poetry.lock
  3. 1 1
      pyproject.toml
  4. 2 2
      reflex/.templates/jinja/web/pages/_app.js.jinja2
  5. 3 1
      reflex/.templates/jinja/web/utils/context.js.jinja2
  6. 3 3
      reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js
  7. 2 1
      reflex/.templates/web/jsconfig.json
  8. 4 4
      reflex/.templates/web/utils/state.js
  9. 1 1
      reflex/app.py
  10. 4 4
      reflex/compiler/compiler.py
  11. 6 0
      reflex/compiler/utils.py
  12. 8 3
      reflex/components/component.py
  13. 2 2
      reflex/components/core/banner.py
  14. 1 1
      reflex/components/core/client_side_routing.py
  15. 1 1
      reflex/components/core/clipboard.py
  16. 1 1
      reflex/components/core/cond.py
  17. 5 1
      reflex/components/core/debounce.py
  18. 4 4
      reflex/components/core/upload.py
  19. 2 2
      reflex/components/core/upload.pyi
  20. 1 1
      reflex/components/datadisplay/dataeditor.py
  21. 3 3
      reflex/components/dynamic.py
  22. 37 23
      reflex/components/el/elements/forms.py
  23. 7 4
      reflex/components/el/elements/forms.pyi
  24. 2 2
      reflex/components/radix/themes/base.py
  25. 1 1
      reflex/components/sonner/toast.py
  26. 43 39
      reflex/config.py
  27. 2 2
      reflex/constants/compiler.py
  28. 72 42
      reflex/event.py
  29. 1 1
      reflex/experimental/client_state.py
  30. 1 1
      reflex/model.py
  31. 33 7
      reflex/state.py
  32. 1 1
      reflex/style.py
  33. 2 1
      reflex/testing.py
  34. 6 0
      reflex/utils/imports.py
  35. 3 3
      reflex/vars/base.py
  36. 1 1
      reflex/vars/number.py
  37. 59 0
      tests/integration/tests_playwright/test_stateless_app.py
  38. 7 7
      tests/units/components/core/test_banner.py
  39. 31 1
      tests/units/test_config.py
  40. 83 0
      tests/units/test_state.py
  41. 1 0
      tests/units/utils/test_format.py

+ 1 - 0
.github/workflows/integration_app_harness.yml

@@ -51,6 +51,7 @@ jobs:
           SCREENSHOT_DIR: /tmp/screenshots
           REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }}
         run: |
+          poetry run playwright install --with-deps
           poetry run pytest tests/integration
       - uses: actions/upload-artifact@v4
         name: Upload failed test screenshots

+ 18 - 17
poetry.lock

@@ -521,6 +521,21 @@ files = [
     {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"},
 ]
 
+[[package]]
+name = "dill"
+version = "0.3.9"
+description = "serialize all of Python"
+optional = false
+python-versions = ">=3.8"
+files = [
+    {file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"},
+    {file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"},
+]
+
+[package.extras]
+graph = ["objgraph (>=1.7.2)"]
+profile = ["gprof2dot (>=2022.7.29)"]
+
 [[package]]
 name = "distlib"
 version = "0.3.9"
@@ -1333,8 +1348,8 @@ files = [
 
 [package.dependencies]
 numpy = [
-    {version = ">=1.26.0", markers = "python_version >= \"3.12\""},
     {version = ">=1.23.2", markers = "python_version == \"3.11\""},
+    {version = ">=1.26.0", markers = "python_version >= \"3.12\""},
     {version = ">=1.22.4", markers = "python_version < \"3.11\""},
 ]
 python-dateutil = ">=2.8.2"
@@ -1652,8 +1667,8 @@ files = [
 annotated-types = ">=0.6.0"
 pydantic-core = "2.23.4"
 typing-extensions = [
-    {version = ">=4.12.2", markers = "python_version >= \"3.13\""},
     {version = ">=4.6.1", markers = "python_version < \"3.13\""},
+    {version = ">=4.12.2", markers = "python_version >= \"3.13\""},
 ]
 
 [package.extras]
@@ -1977,20 +1992,6 @@ files = [
 [package.dependencies]
 six = ">=1.5"
 
-[[package]]
-name = "python-dotenv"
-version = "1.0.1"
-description = "Read key-value pairs from a .env file and set them as environment variables"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
-    {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
-]
-
-[package.extras]
-cli = ["click (>=5.0)"]
-
 [[package]]
 name = "python-engineio"
 version = "4.10.1"
@@ -3047,4 +3048,4 @@ type = ["pytest-mypy"]
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.9"
-content-hash = "c5da15520cef58124f6699007c81158036840469d4f9972592d72bd456c45e7e"
+content-hash = "e03374b85bf10f0a7bb857969b2d6714f25affa63e14a48a88be9fa154b24326"

+ 1 - 1
pyproject.toml

@@ -33,7 +33,6 @@ jinja2 = ">=3.1.2,<4.0"
 psutil = ">=5.9.4,<7.0"
 pydantic = ">=1.10.2,<3.0"
 python-multipart = ">=0.0.5,<0.1"
-python-dotenv = ">=1.0.1"
 python-socketio = ">=5.7.0,<6.0"
 redis = ">=4.3.5,<6.0"
 rich = ">=13.0.0,<14.0"
@@ -66,6 +65,7 @@ pytest = ">=7.1.2,<9.0"
 pytest-mock = ">=3.10.0,<4.0"
 pyright = ">=1.1.229,<1.1.335"
 darglint = ">=1.8.1,<2.0"
+dill = ">=0.3.8"
 toml = ">=0.10.2,<1.0"
 pytest-asyncio = ">=0.24.0"
 pytest-cov = ">=4.0.0,<6.0"

+ 2 - 2
reflex/.templates/jinja/web/pages/_app.js.jinja2

@@ -1,11 +1,11 @@
 {% extends "web/pages/base_page.js.jinja2" %}
 
 {% block early_imports %}
-import '/styles/styles.css'
+import '$/styles/styles.css'
 {% endblock %}
 
 {% block declaration %}
-import { EventLoopProvider, StateProvider, defaultColorMode } from "/utils/context.js";
+import { EventLoopProvider, StateProvider, defaultColorMode } from "$/utils/context.js";
 import { ThemeProvider } from 'next-themes'
 {% for library_alias, library_path in  window_libraries %}
 import * as {{library_alias}} from "{{library_path}}";

+ 3 - 1
reflex/.templates/jinja/web/utils/context.js.jinja2

@@ -1,5 +1,5 @@
 import { createContext, useContext, useMemo, useReducer, useState } from "react"
-import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "/utils/state.js"
+import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "$/utils/state.js"
 
 {% if initial_state %}
 export const initialState = {{ initial_state|json_dumps }}
@@ -59,6 +59,8 @@ export const initialEvents = () => [
 {% else %}
 export const state_name = undefined
 
+export const exception_state_name = undefined
+
 export const onLoadInternalEvent = () => []
 
 export const initialEvents = () => []

+ 3 - 3
reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js

@@ -4,8 +4,8 @@ import {
   ColorModeContext,
   defaultColorMode,
   isDevMode,
-  lastCompiledTimeStamp
-} from "/utils/context.js";
+  lastCompiledTimeStamp,
+} from "$/utils/context.js";
 
 export default function RadixThemesColorModeProvider({ children }) {
   const { theme, resolvedTheme, setTheme } = useTheme();
@@ -37,7 +37,7 @@ export default function RadixThemesColorModeProvider({ children }) {
     const allowedModes = ["light", "dark", "system"];
     if (!allowedModes.includes(mode)) {
       console.error(
-        `Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`,
+        `Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`
       );
       mode = defaultColorMode;
     }

+ 2 - 1
reflex/.templates/web/jsconfig.json

@@ -2,7 +2,8 @@
   "compilerOptions": {
     "baseUrl": ".",
     "paths": {
+      "$/*": ["*"],
       "@/*": ["public/*"]
     }
   }
-}
+}

+ 4 - 4
reflex/.templates/web/utils/state.js

@@ -2,7 +2,7 @@
 import axios from "axios";
 import io from "socket.io-client";
 import JSON5 from "json5";
-import env from "/env.json";
+import env from "$/env.json";
 import Cookies from "universal-cookie";
 import { useEffect, useRef, useState } from "react";
 import Router, { useRouter } from "next/router";
@@ -12,9 +12,9 @@ import {
   onLoadInternalEvent,
   state_name,
   exception_state_name,
-} from "utils/context.js";
-import debounce from "/utils/helpers/debounce";
-import throttle from "/utils/helpers/throttle";
+} from "$/utils/context.js";
+import debounce from "$/utils/helpers/debounce";
+import throttle from "$/utils/helpers/throttle";
 import * as Babel from "@babel/standalone";
 
 // Endpoint URLs.

+ 1 - 1
reflex/app.py

@@ -679,7 +679,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
             for i, tags in imports.items()
             if i not in constants.PackageJson.DEPENDENCIES
             and i not in constants.PackageJson.DEV_DEPENDENCIES
-            and not any(i.startswith(prefix) for prefix in ["/", ".", "next/"])
+            and not any(i.startswith(prefix) for prefix in ["/", "$/", ".", "next/"])
             and i != ""
             and any(tag.install for tag in tags)
         }

+ 4 - 4
reflex/compiler/compiler.py

@@ -67,8 +67,8 @@ def _compile_app(app_root: Component) -> str:
     window_libraries = [
         (_normalize_library_name(name), name) for name in bundled_libraries
     ] + [
-        ("utils_context", f"/{constants.Dirs.UTILS}/context"),
-        ("utils_state", f"/{constants.Dirs.UTILS}/state"),
+        ("utils_context", f"$/{constants.Dirs.UTILS}/context"),
+        ("utils_state", f"$/{constants.Dirs.UTILS}/state"),
     ]
 
     return templates.APP_ROOT.render(
@@ -228,7 +228,7 @@ def _compile_components(
     """
     imports = {
         "react": [ImportVar(tag="memo")],
-        f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="E"), ImportVar(tag="isTrue")],
+        f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="E"), ImportVar(tag="isTrue")],
     }
     component_renders = []
 
@@ -315,7 +315,7 @@ def _compile_stateful_components(
     # Don't import from the file that we're about to create.
     all_imports = utils.merge_imports(*all_import_dicts)
     all_imports.pop(
-        f"/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None
+        f"$/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None
     )
 
     return templates.STATEFUL_COMPONENTS.render(

+ 6 - 0
reflex/compiler/utils.py

@@ -83,6 +83,12 @@ def validate_imports(import_dict: ParsedImportDict):
                 f"{_import.tag}/{_import.alias}" if _import.alias else _import.tag
             )
             if import_name in used_tags:
+                already_imported = used_tags[import_name]
+                if (already_imported[0] == "$" and already_imported[1:] == lib) or (
+                    lib[0] == "$" and lib[1:] == already_imported
+                ):
+                    used_tags[import_name] = lib if lib[0] == "$" else already_imported
+                    continue
                 raise ValueError(
                     f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}"
                 )

+ 8 - 3
reflex/components/component.py

@@ -38,6 +38,7 @@ from reflex.constants import (
 )
 from reflex.constants.compiler import SpecialAttributes
 from reflex.event import (
+    EventCallback,
     EventChain,
     EventChainVar,
     EventHandler,
@@ -1126,6 +1127,8 @@ class Component(BaseComponent, ABC):
         for trigger in self.event_triggers.values():
             if isinstance(trigger, EventChain):
                 for event in trigger.events:
+                    if isinstance(event, EventCallback):
+                        continue
                     if isinstance(event, EventSpec):
                         if event.handler.state_full_name:
                             return True
@@ -1305,7 +1308,9 @@ class Component(BaseComponent, ABC):
         if self._get_ref_hook():
             # Handle hooks needed for attaching react refs to DOM nodes.
             _imports.setdefault("react", set()).add(ImportVar(tag="useRef"))
-            _imports.setdefault(f"/{Dirs.STATE_PATH}", set()).add(ImportVar(tag="refs"))
+            _imports.setdefault(f"$/{Dirs.STATE_PATH}", set()).add(
+                ImportVar(tag="refs")
+            )
 
         if self._get_mount_lifecycle_hook():
             # Handle hooks for `on_mount` / `on_unmount`.
@@ -1662,7 +1667,7 @@ class CustomComponent(Component):
     """A custom user-defined component."""
 
     # Use the components library.
-    library = f"/{Dirs.COMPONENTS_PATH}"
+    library = f"$/{Dirs.COMPONENTS_PATH}"
 
     # The function that creates the component.
     component_fn: Callable[..., Component] = Component.create
@@ -2230,7 +2235,7 @@ class StatefulComponent(BaseComponent):
         """
         if self.rendered_as_shared:
             return {
-                f"/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [
+                f"$/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [
                     ImportVar(tag=self.tag)
                 ]
             }

+ 2 - 2
reflex/components/core/banner.py

@@ -66,8 +66,8 @@ class WebsocketTargetURL(Var):
             _js_expr="getBackendURL(env.EVENT).href",
             _var_data=VarData(
                 imports={
-                    "/env.json": [ImportVar(tag="env", is_default=True)],
-                    f"/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
+                    "$/env.json": [ImportVar(tag="env", is_default=True)],
+                    f"$/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
                 },
             ),
             _var_type=WebsocketTargetURL,

+ 1 - 1
reflex/components/core/client_side_routing.py

@@ -21,7 +21,7 @@ route_not_found: Var = Var(_js_expr=constants.ROUTE_NOT_FOUND)
 class ClientSideRouting(Component):
     """The client-side routing component."""
 
-    library = "/utils/client_side_routing"
+    library = "$/utils/client_side_routing"
     tag = "useClientSideRouting"
 
     def add_hooks(self) -> list[str]:

+ 1 - 1
reflex/components/core/clipboard.py

@@ -67,7 +67,7 @@ class Clipboard(Fragment):
             The import dict for the component.
         """
         return {
-            "/utils/helpers/paste.js": ImportVar(
+            "$/utils/helpers/paste.js": ImportVar(
                 tag="usePasteHandler", is_default=True
             ),
         }

+ 1 - 1
reflex/components/core/cond.py

@@ -15,7 +15,7 @@ from reflex.vars.base import LiteralVar, Var
 from reflex.vars.number import ternary_operation
 
 _IS_TRUE_IMPORT: ImportDict = {
-    f"/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
+    f"$/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
 }
 
 

+ 5 - 1
reflex/components/core/debounce.py

@@ -118,7 +118,7 @@ class DebounceInput(Component):
                 _var_type=Type[Component],
                 _var_data=VarData(
                     imports=child._get_imports(),
-                    hooks=child._get_hooks_internal(),
+                    hooks=child._get_all_hooks(),
                 ),
             ),
         )
@@ -128,6 +128,10 @@ class DebounceInput(Component):
         component.event_triggers.update(child.event_triggers)
         component.children = child.children
         component._rename_props = child._rename_props
+        outer_get_all_custom_code = component._get_all_custom_code
+        component._get_all_custom_code = lambda: outer_get_all_custom_code().union(
+            child._get_all_custom_code()
+        )
         return component
 
     def _render(self):

+ 4 - 4
reflex/components/core/upload.py

@@ -29,7 +29,7 @@ DEFAULT_UPLOAD_ID: str = "default"
 upload_files_context_var_data: VarData = VarData(
     imports={
         "react": "useContext",
-        f"/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
+        f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
     },
     hooks={
         "const [filesById, setFilesById] = useContext(UploadFilesContext);": None,
@@ -134,8 +134,8 @@ uploaded_files_url_prefix = Var(
     _js_expr="getBackendURL(env.UPLOAD)",
     _var_data=VarData(
         imports={
-            f"/{Dirs.STATE_PATH}": "getBackendURL",
-            "/env.json": ImportVar(tag="env", is_default=True),
+            f"$/{Dirs.STATE_PATH}": "getBackendURL",
+            "$/env.json": ImportVar(tag="env", is_default=True),
         }
     ),
 ).to(str)
@@ -170,7 +170,7 @@ def _on_drop_spec(files: Var) -> Tuple[Var[Any]]:
 class UploadFilesProvider(Component):
     """AppWrap component that provides a dict of selected files by ID via useContext."""
 
-    library = f"/{Dirs.CONTEXTS_PATH}"
+    library = f"$/{Dirs.CONTEXTS_PATH}"
     tag = "UploadFilesProvider"
 
 

+ 2 - 2
reflex/components/core/upload.pyi

@@ -34,8 +34,8 @@ uploaded_files_url_prefix = Var(
     _js_expr="getBackendURL(env.UPLOAD)",
     _var_data=VarData(
         imports={
-            f"/{Dirs.STATE_PATH}": "getBackendURL",
-            "/env.json": ImportVar(tag="env", is_default=True),
+            f"$/{Dirs.STATE_PATH}": "getBackendURL",
+            "$/env.json": ImportVar(tag="env", is_default=True),
         }
     ),
 ).to(str)

+ 1 - 1
reflex/components/datadisplay/dataeditor.py

@@ -344,7 +344,7 @@ class DataEditor(NoSSRComponent):
         return {
             "": f"{format.format_library_name(self.library)}/dist/index.css",
             self.library: "GridCellKind",
-            "/utils/helpers/dataeditor.js": ImportVar(
+            "$/utils/helpers/dataeditor.js": ImportVar(
                 tag="formatDataEditorCells", is_default=False, install=False
             ),
         }

+ 3 - 3
reflex/components/dynamic.py

@@ -90,7 +90,7 @@ def load_dynamic_serializer():
         for lib, names in component._get_all_imports().items():
             formatted_lib_name = format_library_name(lib)
             if (
-                not lib.startswith((".", "/"))
+                not lib.startswith((".", "/", "$/"))
                 and not lib.startswith("http")
                 and formatted_lib_name not in libs_in_window
             ):
@@ -106,7 +106,7 @@ def load_dynamic_serializer():
         # Rewrite imports from `/` to destructure from window
         for ix, line in enumerate(module_code_lines[:]):
             if line.startswith("import "):
-                if 'from "/' in line:
+                if 'from "$/' in line or 'from "/' in line:
                     module_code_lines[ix] = (
                         line.replace("import ", "const ", 1).replace(
                             " from ", " = window['__reflex'][", 1
@@ -157,7 +157,7 @@ def load_dynamic_serializer():
             merge_var_data=VarData.merge(
                 VarData(
                     imports={
-                        f"/{constants.Dirs.STATE_PATH}": [
+                        f"$/{constants.Dirs.STATE_PATH}": [
                             imports.ImportVar(tag="evalReactComponent"),
                         ],
                         "react": [

+ 37 - 23
reflex/components/el/elements/forms.py

@@ -187,7 +187,7 @@ class Form(BaseHTML):
         """
         return {
             "react": "useCallback",
-            f"/{Dirs.STATE_PATH}": ["getRefValue", "getRefValues"],
+            f"$/{Dirs.STATE_PATH}": ["getRefValue", "getRefValues"],
         }
 
     def add_hooks(self) -> list[str]:
@@ -615,6 +615,42 @@ class Textarea(BaseHTML):
     # Fired when a key is released
     on_key_up: EventHandler[key_event]
 
+    @classmethod
+    def create(cls, *children, **props):
+        """Create a textarea component.
+
+        Args:
+            *children: The children of the textarea.
+            **props: The properties of the textarea.
+
+        Returns:
+            The textarea component.
+
+        Raises:
+            ValueError: when `enter_key_submit` is combined with `on_key_down`.
+        """
+        enter_key_submit = props.get("enter_key_submit")
+        auto_height = props.get("auto_height")
+        custom_attrs = props.setdefault("custom_attrs", {})
+
+        if enter_key_submit is not None:
+            enter_key_submit = Var.create(enter_key_submit)
+            if "on_key_down" in props:
+                raise ValueError(
+                    "Cannot combine `enter_key_submit` with `on_key_down`.",
+                )
+            custom_attrs["on_key_down"] = Var(
+                _js_expr=f"(e) => enterKeySubmitOnKeyDown(e, {str(enter_key_submit)})",
+                _var_data=VarData.merge(enter_key_submit._get_all_var_data()),
+            )
+        if auto_height is not None:
+            auto_height = Var.create(auto_height)
+            custom_attrs["on_input"] = Var(
+                _js_expr=f"(e) => autoHeightOnInput(e, {str(auto_height)})",
+                _var_data=VarData.merge(auto_height._get_all_var_data()),
+            )
+        return super().create(*children, **props)
+
     def _exclude_props(self) -> list[str]:
         return super()._exclude_props() + [
             "auto_height",
@@ -634,28 +670,6 @@ class Textarea(BaseHTML):
             custom_code.add(ENTER_KEY_SUBMIT_JS)
         return custom_code
 
-    def _render(self) -> Tag:
-        tag = super()._render()
-        if self.enter_key_submit is not None:
-            if "on_key_down" in self.event_triggers:
-                raise ValueError(
-                    "Cannot combine `enter_key_submit` with `on_key_down`.",
-                )
-            tag.add_props(
-                on_key_down=Var(
-                    _js_expr=f"(e) => enterKeySubmitOnKeyDown(e, {str(self.enter_key_submit)})",
-                    _var_data=VarData.merge(self.enter_key_submit._get_all_var_data()),
-                )
-            )
-        if self.auto_height is not None:
-            tag.add_props(
-                on_input=Var(
-                    _js_expr=f"(e) => autoHeightOnInput(e, {str(self.auto_height)})",
-                    _var_data=VarData.merge(self.auto_height._get_all_var_data()),
-                )
-            )
-        return tag
-
 
 button = Button.create
 fieldset = Fieldset.create

+ 7 - 4
reflex/components/el/elements/forms.pyi

@@ -1376,10 +1376,10 @@ class Textarea(BaseHTML):
         on_unmount: Optional[EventType[[]]] = None,
         **props,
     ) -> "Textarea":
-        """Create the component.
+        """Create a textarea component.
 
         Args:
-            *children: The children of the component.
+            *children: The children of the textarea.
             auto_complete: Whether the form control should have autocomplete enabled
             auto_focus: Automatically focuses the textarea when the page loads
             auto_height: Automatically fit the content height to the text (use min-height with this prop)
@@ -1419,10 +1419,13 @@ class Textarea(BaseHTML):
             class_name: The class name for the component.
             autofocus: Whether the component should take the focus once the page is loaded
             custom_attrs: custom attribute
-            **props: The props of the component.
+            **props: The properties of the textarea.
 
         Returns:
-            The component.
+            The textarea component.
+
+        Raises:
+            ValueError: when `enter_key_submit` is combined with `on_key_down`.
         """
         ...
 

+ 2 - 2
reflex/components/radix/themes/base.py

@@ -221,7 +221,7 @@ class Theme(RadixThemesComponent):
             The import dict.
         """
         _imports: ImportDict = {
-            "/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
+            "$/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
         }
         if get_config().tailwind is None:
             # When tailwind is disabled, import the radix-ui styles directly because they will
@@ -265,7 +265,7 @@ class ThemePanel(RadixThemesComponent):
 class RadixThemesColorModeProvider(Component):
     """Next-themes integration for radix themes components."""
 
-    library = "/components/reflex/radix_themes_color_mode_provider.js"
+    library = "$/components/reflex/radix_themes_color_mode_provider.js"
     tag = "RadixThemesColorModeProvider"
     is_default = True
 

+ 1 - 1
reflex/components/sonner/toast.py

@@ -251,7 +251,7 @@ class Toaster(Component):
             _js_expr=f"{toast_ref} = toast",
             _var_data=VarData(
                 imports={
-                    "/utils/state": [ImportVar(tag="refs")],
+                    "$/utils/state": [ImportVar(tag="refs")],
                     self.library: [ImportVar(tag="toast", install=False)],
                 }
             ),

+ 43 - 39
reflex/config.py

@@ -8,12 +8,12 @@ import os
 import sys
 import urllib.parse
 from pathlib import Path
-from typing import Any, Dict, List, Optional, Set, Union
+from typing import Any, Dict, List, Optional, Set
 
 from typing_extensions import get_type_hints
 
 from reflex.utils.exceptions import ConfigError, EnvironmentVarValueError
-from reflex.utils.types import value_inside_optional
+from reflex.utils.types import GenericType, is_union, value_inside_optional
 
 try:
     import pydantic.v1 as pydantic
@@ -157,11 +157,13 @@ def get_default_value_for_field(field: dataclasses.Field) -> Any:
         )
 
 
-def interpret_boolean_env(value: str) -> bool:
+# TODO: Change all interpret_.* signatures to value: str, field: dataclasses.Field once we migrate rx.Config to dataclasses
+def interpret_boolean_env(value: str, field_name: str) -> bool:
     """Interpret a boolean environment variable value.
 
     Args:
         value: The environment variable value.
+        field_name: The field name.
 
     Returns:
         The interpreted value.
@@ -176,14 +178,15 @@ def interpret_boolean_env(value: str) -> bool:
         return True
     elif value.lower() in false_values:
         return False
-    raise EnvironmentVarValueError(f"Invalid boolean value: {value}")
+    raise EnvironmentVarValueError(f"Invalid boolean value: {value} for {field_name}")
 
 
-def interpret_int_env(value: str) -> int:
+def interpret_int_env(value: str, field_name: str) -> int:
     """Interpret an integer environment variable value.
 
     Args:
         value: The environment variable value.
+        field_name: The field name.
 
     Returns:
         The interpreted value.
@@ -194,14 +197,17 @@ def interpret_int_env(value: str) -> int:
     try:
         return int(value)
     except ValueError as ve:
-        raise EnvironmentVarValueError(f"Invalid integer value: {value}") from ve
+        raise EnvironmentVarValueError(
+            f"Invalid integer value: {value} for {field_name}"
+        ) from ve
 
 
-def interpret_path_env(value: str) -> Path:
+def interpret_path_env(value: str, field_name: str) -> Path:
     """Interpret a path environment variable value.
 
     Args:
         value: The environment variable value.
+        field_name: The field name.
 
     Returns:
         The interpreted value.
@@ -211,16 +217,19 @@ def interpret_path_env(value: str) -> Path:
     """
     path = Path(value)
     if not path.exists():
-        raise EnvironmentVarValueError(f"Path does not exist: {path}")
+        raise EnvironmentVarValueError(f"Path does not exist: {path} for {field_name}")
     return path
 
 
-def interpret_env_var_value(value: str, field: dataclasses.Field) -> Any:
+def interpret_env_var_value(
+    value: str, field_type: GenericType, field_name: str
+) -> Any:
     """Interpret an environment variable value based on the field type.
 
     Args:
         value: The environment variable value.
-        field: The field.
+        field_type: The field type.
+        field_name: The field name.
 
     Returns:
         The interpreted value.
@@ -228,20 +237,25 @@ def interpret_env_var_value(value: str, field: dataclasses.Field) -> Any:
     Raises:
         ValueError: If the value is invalid.
     """
-    field_type = value_inside_optional(field.type)
+    field_type = value_inside_optional(field_type)
+
+    if is_union(field_type):
+        raise ValueError(
+            f"Union types are not supported for environment variables: {field_name}."
+        )
 
     if field_type is bool:
-        return interpret_boolean_env(value)
+        return interpret_boolean_env(value, field_name)
     elif field_type is str:
         return value
     elif field_type is int:
-        return interpret_int_env(value)
+        return interpret_int_env(value, field_name)
     elif field_type is Path:
-        return interpret_path_env(value)
+        return interpret_path_env(value, field_name)
 
     else:
         raise ValueError(
-            f"Invalid type for environment variable {field.name}: {field_type}. This is probably an issue in Reflex."
+            f"Invalid type for environment variable {field_name}: {field_type}. This is probably an issue in Reflex."
         )
 
 
@@ -316,7 +330,7 @@ class EnvironmentVariables:
             field.type = type_hints.get(field.name) or field.type
 
             value = (
-                interpret_env_var_value(raw_value, field)
+                interpret_env_var_value(raw_value, field.type, field.name)
                 if raw_value is not None
                 else get_default_value_for_field(field)
             )
@@ -387,7 +401,7 @@ class Config(Base):
     telemetry_enabled: bool = True
 
     # The bun path
-    bun_path: Union[str, Path] = constants.Bun.DEFAULT_PATH
+    bun_path: Path = constants.Bun.DEFAULT_PATH
 
     # List of origins that are allowed to connect to the backend API.
     cors_allowed_origins: List[str] = ["*"]
@@ -484,17 +498,17 @@ class Config(Base):
 
         Returns:
             The updated config values.
-
-        Raises:
-            EnvVarValueError: If an environment variable is set to an invalid type.
         """
-        from reflex.utils.exceptions import EnvVarValueError
-
         if self.env_file:
-            from dotenv import load_dotenv
-
-            # load env file if exists
-            load_dotenv(self.env_file, override=True)
+            try:
+                from dotenv import load_dotenv  # type: ignore
+
+                # load env file if exists
+                load_dotenv(self.env_file, override=True)
+            except ImportError:
+                console.error(
+                    """The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`."""
+                )
 
         updated_values = {}
         # Iterate over the fields.
@@ -510,21 +524,11 @@ class Config(Base):
                         dedupe=True,
                     )
 
-                # Convert the env var to the expected type.
-                try:
-                    if issubclass(field.type_, bool):
-                        # special handling for bool values
-                        env_var = env_var.lower() in ["true", "1", "yes"]
-                    else:
-                        env_var = field.type_(env_var)
-                except ValueError as ve:
-                    console.error(
-                        f"Could not convert {key.upper()}={env_var} to type {field.type_}"
-                    )
-                    raise EnvVarValueError from ve
+                # Interpret the value.
+                value = interpret_env_var_value(env_var, field.type_, field.name)
 
                 # Set the value.
-                updated_values[key] = env_var
+                updated_values[key] = value
 
         return updated_values
 

+ 2 - 2
reflex/constants/compiler.py

@@ -118,8 +118,8 @@ class Imports(SimpleNamespace):
 
     EVENTS = {
         "react": [ImportVar(tag="useContext")],
-        f"/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
-        f"/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
+        f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
+        f"$/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
     }
 
 

+ 72 - 42
reflex/event.py

@@ -16,6 +16,7 @@ from typing import (
     Generic,
     List,
     Optional,
+    Sequence,
     Tuple,
     Type,
     TypeVar,
@@ -389,7 +390,9 @@ class CallableEventSpec(EventSpec):
 class EventChain(EventActionsMixin):
     """Container for a chain of events that will be executed in order."""
 
-    events: List[Union[EventSpec, EventVar]] = dataclasses.field(default_factory=list)
+    events: Sequence[Union[EventSpec, EventVar, EventCallback]] = dataclasses.field(
+        default_factory=list
+    )
 
     args_spec: Optional[Callable] = dataclasses.field(default=None)
 
@@ -1445,13 +1448,8 @@ class LiteralEventChainVar(ArgsFunctionOperation, LiteralVar, EventChainVar):
         )
 
 
-G = ParamSpec("G")
-
-IndividualEventType = Union[EventSpec, EventHandler, Callable[G, Any], Var[Any]]
-
-EventType = Union[IndividualEventType[G], List[IndividualEventType[G]]]
-
 P = ParamSpec("P")
+Q = ParamSpec("Q")
 T = TypeVar("T")
 V = TypeVar("V")
 V2 = TypeVar("V2")
@@ -1473,55 +1471,73 @@ if sys.version_info >= (3, 10):
             """
             self.func = func
 
+        @property
+        def prevent_default(self):
+            """Prevent default behavior.
+
+            Returns:
+                The event callback with prevent default behavior.
+            """
+            return self
+
+        @property
+        def stop_propagation(self):
+            """Stop event propagation.
+
+            Returns:
+                The event callback with stop propagation behavior.
+            """
+            return self
+
         @overload
-        def __get__(
-            self: EventCallback[[V], T], instance: None, owner
-        ) -> Callable[[Union[Var[V], V]], EventSpec]: ...
+        def __call__(
+            self: EventCallback[Concatenate[V, Q], T], value: V | Var[V]
+        ) -> EventCallback[Q, T]: ...
 
         @overload
-        def __get__(
-            self: EventCallback[[V, V2], T], instance: None, owner
-        ) -> Callable[[Union[Var[V], V], Union[Var[V2], V2]], EventSpec]: ...
+        def __call__(
+            self: EventCallback[Concatenate[V, V2, Q], T],
+            value: V | Var[V],
+            value2: V2 | Var[V2],
+        ) -> EventCallback[Q, T]: ...
 
         @overload
-        def __get__(
-            self: EventCallback[[V, V2, V3], T], instance: None, owner
-        ) -> Callable[
-            [Union[Var[V], V], Union[Var[V2], V2], Union[Var[V3], V3]],
-            EventSpec,
-        ]: ...
+        def __call__(
+            self: EventCallback[Concatenate[V, V2, V3, Q], T],
+            value: V | Var[V],
+            value2: V2 | Var[V2],
+            value3: V3 | Var[V3],
+        ) -> EventCallback[Q, T]: ...
 
         @overload
-        def __get__(
-            self: EventCallback[[V, V2, V3, V4], T], instance: None, owner
-        ) -> Callable[
-            [
-                Union[Var[V], V],
-                Union[Var[V2], V2],
-                Union[Var[V3], V3],
-                Union[Var[V4], V4],
-            ],
-            EventSpec,
-        ]: ...
+        def __call__(
+            self: EventCallback[Concatenate[V, V2, V3, V4, Q], T],
+            value: V | Var[V],
+            value2: V2 | Var[V2],
+            value3: V3 | Var[V3],
+            value4: V4 | Var[V4],
+        ) -> EventCallback[Q, T]: ...
+
+        def __call__(self, *values) -> EventCallback:  # type: ignore
+            """Call the function with the values.
+
+            Args:
+                *values: The values to call the function with.
+
+            Returns:
+                The function with the values.
+            """
+            return self.func(*values)  # type: ignore
 
         @overload
         def __get__(
-            self: EventCallback[[V, V2, V3, V4, V5], T], instance: None, owner
-        ) -> Callable[
-            [
-                Union[Var[V], V],
-                Union[Var[V2], V2],
-                Union[Var[V3], V3],
-                Union[Var[V4], V4],
-                Union[Var[V5], V5],
-            ],
-            EventSpec,
-        ]: ...
+            self: EventCallback[P, T], instance: None, owner
+        ) -> EventCallback[P, T]: ...
 
         @overload
         def __get__(self, instance, owner) -> Callable[P, T]: ...
 
-        def __get__(self, instance, owner) -> Callable:
+        def __get__(self, instance, owner) -> Callable:  # type: ignore
             """Get the function with the instance bound to it.
 
             Args:
@@ -1548,6 +1564,9 @@ if sys.version_info >= (3, 10):
         return func  # type: ignore
 else:
 
+    class EventCallback(Generic[P, T]):
+        """A descriptor that wraps a function to be used as an event."""
+
     def event_handler(func: Callable[P, T]) -> Callable[P, T]:
         """Wrap a function to be used as an event.
 
@@ -1560,6 +1579,17 @@ else:
         return func
 
 
+G = ParamSpec("G")
+
+IndividualEventType = Union[
+    EventSpec, EventHandler, Callable[G, Any], EventCallback[G, Any], Var[Any]
+]
+
+ItemOrList = Union[V, List[V]]
+
+EventType = ItemOrList[IndividualEventType[G]]
+
+
 class EventNamespace(types.SimpleNamespace):
     """A namespace for event related classes."""
 

+ 1 - 1
reflex/experimental/client_state.py

@@ -21,7 +21,7 @@ NoValue = object()
 
 
 _refs_import = {
-    f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
+    f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
 }
 
 

+ 1 - 1
reflex/model.py

@@ -38,7 +38,7 @@ def get_engine(url: str | None = None) -> sqlalchemy.engine.Engine:
     url = url or conf.db_url
     if url is None:
         raise ValueError("No database url configured")
-    if environment.ALEMBIC_CONFIG.exists():
+    if not environment.ALEMBIC_CONFIG.exists():
         console.warn(
             "Database is not initialized, run [bold]reflex db init[/bold] first."
         )

+ 33 - 7
reflex/state.py

@@ -218,6 +218,7 @@ class EventHandlerSetVar(EventHandler):
         Raises:
             AttributeError: If the given Var name does not exist on the state.
             EventHandlerValueError: If the given Var name is not a str
+            NotImplementedError: If the setter for the given Var is async
         """
         from reflex.utils.exceptions import EventHandlerValueError
 
@@ -226,11 +227,20 @@ class EventHandlerSetVar(EventHandler):
                 raise EventHandlerValueError(
                     f"Var name must be passed as a string, got {args[0]!r}"
                 )
+
+            handler = getattr(self.state_cls, constants.SETTER_PREFIX + args[0], None)
+
             # Check that the requested Var setter exists on the State at compile time.
-            if getattr(self.state_cls, constants.SETTER_PREFIX + args[0], None) is None:
+            if handler is None:
                 raise AttributeError(
                     f"Variable `{args[0]}` cannot be set on `{self.state_cls.get_full_name()}`"
                 )
+
+            if asyncio.iscoroutinefunction(handler.fn):
+                raise NotImplementedError(
+                    f"Setter for {args[0]} is async, which is not supported."
+                )
+
         return super().__call__(*args)
 
 
@@ -2053,12 +2063,24 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow):
         """
         try:
             return pickle.dumps((self._to_schema(), self))
-        except pickle.PicklingError:
-            console.warn(
+        except (pickle.PicklingError, AttributeError) as og_pickle_error:
+            error = (
                 f"Failed to serialize state {self.get_full_name()} due to unpicklable object. "
-                "This state will not be persisted."
+                "This state will not be persisted. "
             )
-            return b""
+            try:
+                import dill
+
+                return dill.dumps((self._to_schema(), self))
+            except ImportError:
+                error += (
+                    f"Pickle error: {og_pickle_error}. "
+                    "Consider `pip install 'dill>=0.3.8'` for more exotic serialization support."
+                )
+            except (pickle.PicklingError, TypeError, ValueError) as ex:
+                error += f"Dill was also unable to pickle the state: {ex}"
+        console.warn(error)
+        return b""
 
     @classmethod
     def _deserialize(
@@ -2725,9 +2747,13 @@ class StateManagerDisk(StateManager):
         for substate in state.get_substates():
             substate_token = _substate_key(client_token, substate)
 
+            fresh_instance = await root_state.get_state(substate)
             instance = await self.load_state(substate_token)
-            if instance is None:
-                instance = await root_state.get_state(substate)
+            if instance is not None:
+                # Ensure all substates exist, even if they weren't serialized previously.
+                instance.substates = fresh_instance.substates
+            else:
+                instance = fresh_instance
             state.substates[substate.get_name()] = instance
             instance.parent_state = state
 

+ 1 - 1
reflex/style.py

@@ -23,7 +23,7 @@ LiteralColorMode = Literal["system", "light", "dark"]
 
 # Reference the global ColorModeContext
 color_mode_imports = {
-    f"/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
+    f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
     "react": [ImportVar(tag="useContext")],
 }
 

+ 2 - 1
reflex/testing.py

@@ -249,7 +249,8 @@ class AppHarness:
         return textwrap.dedent(source)
 
     def _initialize_app(self):
-        os.environ["TELEMETRY_ENABLED"] = ""  # disable telemetry reporting for tests
+        # disable telemetry reporting for tests
+        os.environ["TELEMETRY_ENABLED"] = "false"
         self.app_path.mkdir(parents=True, exist_ok=True)
         if self.app_source is not None:
             app_globals = self._get_globals_from_signature(self.app_source)

+ 6 - 0
reflex/utils/imports.py

@@ -23,6 +23,12 @@ def merge_imports(
         for lib, fields in (
             import_dict if isinstance(import_dict, tuple) else import_dict.items()
         ):
+            # If the lib is an absolute path, we need to prefix it with a $
+            lib = (
+                "$" + lib
+                if lib.startswith(("/utils/", "/components/", "/styles/", "/public/"))
+                else lib
+            )
             if isinstance(fields, (list, tuple, set)):
                 all_imports[lib].extend(
                     (

+ 3 - 3
reflex/vars/base.py

@@ -217,7 +217,7 @@ class VarData:
                 ): None
             },
             imports={
-                f"/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
+                f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
                 "react": [ImportVar(tag="useContext")],
             },
         )
@@ -956,7 +956,7 @@ class Var(Generic[VAR_TYPE]):
             _js_expr="refs",
             _var_data=VarData(
                 imports={
-                    f"/{constants.Dirs.STATE_PATH}": [imports.ImportVar(tag="refs")]
+                    f"$/{constants.Dirs.STATE_PATH}": [imports.ImportVar(tag="refs")]
                 }
             ),
         ).to(ObjectVar, Dict[str, str])
@@ -2530,7 +2530,7 @@ def get_uuid_string_var() -> Var:
     unique_uuid_var = get_unique_variable_name()
     unique_uuid_var_data = VarData(
         imports={
-            f"/{constants.Dirs.STATE_PATH}": {ImportVar(tag="generateUUID")},  # type: ignore
+            f"$/{constants.Dirs.STATE_PATH}": {ImportVar(tag="generateUUID")},  # type: ignore
             "react": "useMemo",
         },
         hooks={f"const {unique_uuid_var} = useMemo(generateUUID, [])": None},

+ 1 - 1
reflex/vars/number.py

@@ -1090,7 +1090,7 @@ boolean_types = Union[BooleanVar, bool]
 
 
 _IS_TRUE_IMPORT: ImportDict = {
-    f"/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
+    f"$/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
 }
 
 

+ 59 - 0
tests/integration/tests_playwright/test_stateless_app.py

@@ -0,0 +1,59 @@
+"""Integration tests for a stateless app."""
+
+from typing import Generator
+
+import httpx
+import pytest
+from playwright.sync_api import Page, expect
+
+import reflex as rx
+from reflex.testing import AppHarness
+
+
+def StatelessApp():
+    """A stateless app that renders a heading."""
+    import reflex as rx
+
+    def index():
+        return rx.heading("This is a stateless app")
+
+    app = rx.App()
+    app.add_page(index)
+
+
+@pytest.fixture(scope="module")
+def stateless_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
+    """Create a stateless app AppHarness.
+
+    Args:
+        tmp_path_factory: pytest fixture for creating temporary directories.
+
+    Yields:
+        AppHarness: A harness for testing the stateless app.
+    """
+    with AppHarness.create(
+        root=tmp_path_factory.mktemp("stateless_app"),
+        app_source=StatelessApp,  # type: ignore
+    ) as harness:
+        yield harness
+
+
+def test_statelessness(stateless_app: AppHarness, page: Page):
+    """Test that the stateless app renders a heading but backend/_event is not mounted.
+
+    Args:
+        stateless_app: A harness for testing the stateless app.
+        page: A Playwright page.
+    """
+    assert stateless_app.frontend_url is not None
+    assert stateless_app.backend is not None
+    assert stateless_app.backend.started
+
+    res = httpx.get(rx.config.get_config().api_url + "/_event")
+    assert res.status_code == 404
+
+    res2 = httpx.get(rx.config.get_config().api_url + "/ping")
+    assert res2.status_code == 200
+
+    page.goto(stateless_app.frontend_url)
+    expect(page.get_by_role("heading")).to_have_text("This is a stateless app")

+ 7 - 7
tests/units/components/core/test_banner.py

@@ -12,7 +12,7 @@ def test_websocket_target_url():
     var_data = url._get_all_var_data()
     assert var_data is not None
     assert sorted(tuple((key for key, _ in var_data.imports))) == sorted(
-        ("/utils/state", "/env.json")
+        ("$/utils/state", "$/env.json")
     )
 
 
@@ -22,10 +22,10 @@ def test_connection_banner():
     assert sorted(tuple(_imports)) == sorted(
         (
             "react",
-            "/utils/context",
-            "/utils/state",
+            "$/utils/context",
+            "$/utils/state",
             "@radix-ui/themes@^3.0.0",
-            "/env.json",
+            "$/env.json",
         )
     )
 
@@ -40,10 +40,10 @@ def test_connection_modal():
     assert sorted(tuple(_imports)) == sorted(
         (
             "react",
-            "/utils/context",
-            "/utils/state",
+            "$/utils/context",
+            "$/utils/state",
             "@radix-ui/themes@^3.0.0",
-            "/env.json",
+            "$/env.json",
         )
     )
 

+ 31 - 1
tests/units/test_config.py

@@ -1,5 +1,7 @@
 import multiprocessing
 import os
+from pathlib import Path
+from typing import Any, Dict
 
 import pytest
 
@@ -42,7 +44,12 @@ def test_set_app_name(base_config_values):
         ("TELEMETRY_ENABLED", True),
     ],
 )
-def test_update_from_env(base_config_values, monkeypatch, env_var, value):
+def test_update_from_env(
+    base_config_values: Dict[str, Any],
+    monkeypatch: pytest.MonkeyPatch,
+    env_var: str,
+    value: Any,
+):
     """Test that environment variables override config values.
 
     Args:
@@ -57,6 +64,29 @@ def test_update_from_env(base_config_values, monkeypatch, env_var, value):
     assert getattr(config, env_var.lower()) == value
 
 
+def test_update_from_env_path(
+    base_config_values: Dict[str, Any],
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+):
+    """Test that environment variables override config values.
+
+    Args:
+        base_config_values: Config values.
+        monkeypatch: The pytest monkeypatch object.
+        tmp_path: The pytest tmp_path fixture object.
+    """
+    monkeypatch.setenv("BUN_PATH", "/test")
+    assert os.environ.get("BUN_PATH") == "/test"
+    with pytest.raises(ValueError):
+        rx.Config(**base_config_values)
+
+    monkeypatch.setenv("BUN_PATH", str(tmp_path))
+    assert os.environ.get("BUN_PATH") == str(tmp_path)
+    config = rx.Config(**base_config_values)
+    assert config.bun_path == tmp_path
+
+
 @pytest.mark.parametrize(
     "kwargs, expected",
     [

+ 83 - 0
tests/units/test_state.py

@@ -105,6 +105,7 @@ class TestState(BaseState):
     fig: Figure = Figure()
     dt: datetime.datetime = datetime.datetime.fromisoformat("1989-11-09T18:53:00+01:00")
     _backend: int = 0
+    asynctest: int = 0
 
     @ComputedVar
     def sum(self) -> float:
@@ -128,6 +129,14 @@ class TestState(BaseState):
         """Do something."""
         pass
 
+    async def set_asynctest(self, value: int):
+        """Set the asynctest value. Intentionally overwrite the default setter with an async one.
+
+        Args:
+            value: The new value.
+        """
+        self.asynctest = value
+
 
 class ChildState(TestState):
     """A child state fixture."""
@@ -312,6 +321,7 @@ def test_class_vars(test_state):
         "upper",
         "fig",
         "dt",
+        "asynctest",
     }
 
 
@@ -732,6 +742,7 @@ def test_reset(test_state, child_state):
         "mapping",
         "dt",
         "_backend",
+        "asynctest",
     }
 
     # The dirty vars should be reset.
@@ -3180,6 +3191,13 @@ async def test_setvar(mock_app: rx.App, token: str):
         TestState.setvar(42, 42)
 
 
+@pytest.mark.asyncio
+async def test_setvar_async_setter():
+    """Test that overridden async setters raise Exception when used with setvar."""
+    with pytest.raises(NotImplementedError):
+        TestState.setvar("asynctest", 42)
+
+
 @pytest.mark.skipif("REDIS_URL" not in os.environ, reason="Test requires redis")
 @pytest.mark.parametrize(
     "expiration_kwargs, expected_values",
@@ -3315,3 +3333,68 @@ def test_assignment_to_undeclared_vars():
 
     state.handle_supported_regular_vars()
     state.handle_non_var()
+
+
+@pytest.mark.asyncio
+async def test_deserialize_gc_state_disk(token):
+    """Test that a state can be deserialized from disk with a grandchild state.
+
+    Args:
+        token: A token.
+    """
+
+    class Root(BaseState):
+        pass
+
+    class State(Root):
+        num: int = 42
+
+    class Child(State):
+        foo: str = "bar"
+
+    dsm = StateManagerDisk(state=Root)
+    async with dsm.modify_state(token) as root:
+        s = await root.get_state(State)
+        s.num += 1
+        c = await root.get_state(Child)
+        assert s._get_was_touched()
+        assert not c._get_was_touched()
+
+    dsm2 = StateManagerDisk(state=Root)
+    root = await dsm2.get_state(token)
+    s = await root.get_state(State)
+    assert s.num == 43
+    c = await root.get_state(Child)
+    assert c.foo == "bar"
+
+
+class Obj(Base):
+    """A object containing a callable for testing fallback pickle."""
+
+    _f: Callable
+
+
+def test_fallback_pickle():
+    """Test that state serialization will fall back to dill."""
+
+    class DillState(BaseState):
+        _o: Optional[Obj] = None
+        _f: Optional[Callable] = None
+        _g: Any = None
+
+    state = DillState(_reflex_internal_init=True)  # type: ignore
+    state._o = Obj(_f=lambda: 42)
+    state._f = lambda: 420
+
+    pk = state._serialize()
+
+    unpickled_state = BaseState._deserialize(pk)
+    assert unpickled_state._f() == 420
+    assert unpickled_state._o._f() == 42
+
+    # Some object, like generator, are still unpicklable with dill.
+    state._g = (i for i in range(10))
+    pk = state._serialize()
+    assert len(pk) == 0
+    with pytest.raises(EOFError):
+        BaseState._deserialize(pk)

+ 1 - 0
tests/units/utils/test_format.py

@@ -601,6 +601,7 @@ formatted_router = {
                     "sum": 3.14,
                     "upper": "",
                     "router": formatted_router,
+                    "asynctest": 0,
                 },
                 ChildState.get_full_name(): {
                     "count": 23,