Procházet zdrojové kódy

Merge branch 'main' into move-tailwind-to-its-own-module

Khaleel Al-Adhami před 1 měsícem
rodič
revize
0495ff7066
53 změnil soubory, kde provedl 1375 přidání a 1172 odebrání
  1. 0 36
      .github/actions/setup_build_env/action.yml
  2. 6 16
      .github/workflows/benchmarks.yml
  3. 1 2
      .github/workflows/check_node_latest.yml
  4. 2 2
      .github/workflows/check_outdated_dependencies.yml
  5. 1 1
      .github/workflows/integration_app_harness.yml
  6. 5 17
      .github/workflows/integration_tests.yml
  7. 1 1
      .github/workflows/performance.yml
  8. 1 1
      .github/workflows/pre-commit.yml
  9. 2 14
      .github/workflows/unit_tests.yml
  10. 1 0
      .python-version
  11. 2 3
      CONTRIBUTING.md
  12. 5 5
      pyproject.toml
  13. 3 3
      reflex/.templates/web/utils/state.js
  14. 135 62
      reflex/app.py
  15. 2 2
      reflex/app_mixins/lifespan.py
  16. 1 4
      reflex/compiler/compiler.py
  17. 39 57
      reflex/components/component.py
  18. 8 0
      reflex/components/core/upload.py
  19. 9 1
      reflex/components/dynamic.py
  20. 0 21
      reflex/components/markdown/markdown.py
  21. 1 1
      reflex/components/radix/primitives/accordion.py
  22. 1 1
      reflex/components/radix/primitives/form.py
  23. 1 1
      reflex/components/radix/primitives/progress.py
  24. 1 1
      reflex/components/radix/primitives/slider.py
  25. 2 2
      reflex/components/recharts/recharts.py
  26. 1 1
      reflex/components/sonner/toast.py
  27. 4 4
      reflex/config.py
  28. 2 0
      reflex/constants/__init__.py
  29. 21 0
      reflex/constants/base.py
  30. 7 0
      reflex/constants/config.py
  31. 6 6
      reflex/constants/installer.py
  32. 67 64
      reflex/custom_components/custom_components.py
  33. 8 0
      reflex/page.py
  34. 279 267
      reflex/reflex.py
  35. 1 0
      reflex/state.py
  36. 12 5
      reflex/testing.py
  37. 6 2
      reflex/utils/codespaces.py
  38. 4 3
      reflex/utils/console.py
  39. 59 21
      reflex/utils/exec.py
  40. 38 32
      reflex/utils/prerequisites.py
  41. 6 6
      reflex/utils/processes.py
  42. 5 4
      reflex/utils/pyi_generator.py
  43. 11 6
      reflex/utils/types.py
  44. 4 0
      reflex/vars/base.py
  45. 1 1
      tests/integration/test_lifespan.py
  46. 81 9
      tests/integration/test_upload.py
  47. 9 5
      tests/units/components/test_component.py
  48. 9 9
      tests/units/states/upload.py
  49. 49 8
      tests/units/test_app.py
  50. 3 1
      tests/units/test_health_endpoint.py
  51. 2 2
      tests/units/test_prerequisites.py
  52. 5 5
      tests/units/utils/test_utils.py
  53. 445 457
      uv.lock

+ 0 - 36
.github/actions/setup_build_env/action.yml

@@ -6,7 +6,6 @@
 #
 # Exit conditions:
 # - Python of version `python-version` is ready to be invoked as `python`.
-# - Uv of version `uv-version` is ready to be invoked as `uv`.
 # - If `run-uv-sync` is true, deps as defined in `pyproject.toml` will have been installed into the venv at `create-venv-at-path`.
 
 name: "Setup Reflex build environment"
@@ -15,10 +14,6 @@ inputs:
   python-version:
     description: "Python version setup"
     required: true
-  uv-version:
-    description: "Uv version to install"
-    required: false
-    default: "0.6.9"
   run-uv-sync:
     description: "Whether to run uv sync on current dir"
     required: false
@@ -34,38 +29,7 @@ runs:
     - name: Install UV
       uses: astral-sh/setup-uv@v5
       with:
-        version: ${{ inputs.uv-version }}
         python-version: ${{ inputs.python-version }}
         enable-cache: true
         prune-cache: false
         cache-dependency-glob: "uv.lock"
-
-    - name: Restore cached project python deps
-      id: restore-pydeps-cache
-      uses: actions/cache/restore@v4
-      with:
-        path: ${{ inputs.create-venv-at-path }}
-        key: ${{ runner.os }}-python-${{ inputs.python-version }}-pydeps-${{ hashFiles('**/uv.lock') }}
-
-    - if: ${{ inputs.run-uv-sync == 'true' && steps.restore-pydeps-cache.outputs.cache-hit != 'true' }}
-      name: Run uv sync (will get cached)
-      # We skip over installing the root package (the current project code under CI)
-      # Root package should not be cached - its content is not reflected in uv.lock / cache key
-      shell: bash
-      run: |
-        uv sync --all-extras --dev --no-install-project
-
-    - if: steps.restore-pydeps-cache.outputs.cache-hit != 'true'
-      name: Save Python deps to cache
-      uses: actions/cache/save@v4
-      with:
-        path: ${{ inputs.create-venv-at-path }}
-        key: ${{ steps.restore-pydeps-cache.outputs.cache-primary-key }}
-
-    - if: ${{ inputs.run-uv-sync == 'true' }}
-      name: Run uv sync (root package)
-      # Here we really install the root package (the current project code under CI).env:
-      # This should not be cached.
-      shell: bash
-      run: |
-        uv sync --all-extras --dev

+ 6 - 16
.github/workflows/benchmarks.yml

@@ -25,22 +25,13 @@ jobs:
     #    if: github.event.pull_request.merged == true
     strategy:
       fail-fast: false
-      matrix:
-        # Show OS combos first in GUI
-        os: [ubuntu-latest]
-        python-version: ["3.12.8"]
-        node-version: ["18.x"]
 
-    runs-on: ${{ matrix.os }}
+    runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
-      - name: Use Node.js ${{ matrix.node-version }}
-        uses: actions/setup-node@v4
-        with:
-          node-version: ${{ matrix.node-version }}
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: ${{ matrix.python-version }}
+          python-version: 3.13
           run-uv-sync: true
 
       - name: Clone Reflex Website Repo
@@ -80,7 +71,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: 3.12.8
+          python-version: 3.13
           run-uv-sync: true
 
       - name: Build reflex
@@ -90,7 +81,7 @@ jobs:
         # Only run if the database creds are available in this context.
         run:
           uv run python benchmarks/benchmark_package_size.py --os ubuntu-latest
-          --python-version 3.12.8 --commit-sha "${{ github.sha }}" --pr-id "${{ github.event.pull_request.id }}"
+          --python-version 3.13 --commit-sha "${{ github.sha }}" --pr-id "${{ github.event.pull_request.id }}"
           --branch-name "${{ github.head_ref || github.ref_name }}"
           --path ./dist
 
@@ -103,7 +94,6 @@ jobs:
       matrix:
         # Show OS combos first in GUI
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: ["3.12.8"]
 
     runs-on: ${{ matrix.os }}
     steps:
@@ -112,7 +102,7 @@ jobs:
         id: setup-python
         uses: actions/setup-python@v5
         with:
-          python-version: ${{ matrix.python-version }}
+          python-version: 3.13
       - name: Install UV
         uses: astral-sh/setup-uv@v5
         with:
@@ -126,7 +116,7 @@ jobs:
       - name: calculate and upload size
         run:
           uv run python benchmarks/benchmark_package_size.py --os "${{ matrix.os }}"
-          --python-version "${{ matrix.python-version }}" --commit-sha "${{ github.sha }}"
+          --python-version "3.13" --commit-sha "${{ github.sha }}"
           --pr-id "${{ github.event.pull_request.id }}"
           --branch-name "${{ github.head_ref || github.ref_name }}"
           --path ./.venv

+ 1 - 2
.github/workflows/check_node_latest.yml

@@ -18,7 +18,6 @@ jobs:
     runs-on: ubuntu-22.04
     strategy:
       matrix:
-        python-version: ["3.12.8"]
         split_index: [1, 2]
         node-version: ["node"]
       fail-fast: false
@@ -27,7 +26,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: ${{ matrix.python-version }}
+          python-version: 3.13
           run-uv-sync: true
 
       - uses: actions/setup-node@v4

+ 2 - 2
.github/workflows/check_outdated_dependencies.yml

@@ -18,7 +18,7 @@ jobs:
 
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: "3.10"
+          python-version: 3.13
           run-uv-sync: true
 
       - name: Check outdated backend dependencies
@@ -45,7 +45,7 @@ jobs:
         uses: actions/checkout@v4
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: "3.10.16"
+          python-version: 3.13
           run-uv-sync: true
 
       - name: Clone Reflex Website Repo

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

@@ -25,7 +25,7 @@ jobs:
     strategy:
       matrix:
         state_manager: ["redis", "memory"]
-        python-version: ["3.11.11", "3.12.8", "3.13.1"]
+        python-version: ["3.11", "3.12", "3.13"]
         split_index: [1, 2]
       fail-fast: false
     runs-on: ubuntu-22.04

+ 5 - 17
.github/workflows/integration_tests.yml

@@ -43,17 +43,7 @@ jobs:
       matrix:
         # Show OS combos first in GUI
         os: [ubuntu-latest, windows-latest]
-        python-version: ["3.10.16", "3.11.11", "3.12.8", "3.13.1"]
-        exclude:
-          - os: windows-latest
-            python-version: "3.11.11"
-          - os: windows-latest
-            python-version: "3.10.16"
-        include:
-          - os: windows-latest
-            python-version: "3.11.9"
-          - os: windows-latest
-            python-version: "3.10.11"
+        python-version: ["3.10", "3.11", "3.12", "3.13"]
 
     runs-on: ${{ matrix.os }}
     steps:
@@ -114,13 +104,11 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        # Show OS combos first in GUI
-        os: [ubuntu-latest]
-        python-version: ["3.11.11", "3.12.8"]
+        python-version: ["3.11", "3.12"]
 
     env:
       REFLEX_WEB_WINDOWS_OVERRIDE: "1"
-    runs-on: ${{ matrix.os }}
+    runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup_build_env
@@ -155,7 +143,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: "3.11.11"
+          python-version: 3.13
           run-uv-sync: true
 
       - name: Create app directory
@@ -181,7 +169,7 @@ jobs:
       fail-fast: false
       matrix:
         # Note: py311 version chosen due to available arm64 darwin builds.
-        python-version: ["3.11.9", "3.12.8"]
+        python-version: ["3.11", "3.12"]
     runs-on: macos-latest
     steps:
       - uses: actions/checkout@v4

+ 1 - 1
.github/workflows/performance.yml

@@ -31,7 +31,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.12.8"
+          python-version: "3.13"
 
       - name: Install dependencies
         run: uv sync --all-extras --dev

+ 1 - 1
.github/workflows/pre-commit.yml

@@ -23,7 +23,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: ./.github/actions/setup_build_env
         with:
-          python-version: 3.13.2
+          python-version: 3.13
           run-uv-sync: true
       - uses: actions/checkout@v4
         with:

+ 2 - 14
.github/workflows/unit_tests.yml

@@ -28,18 +28,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest, windows-latest]
-        python-version: ["3.10.16", "3.11.11", "3.12.8", "3.13.1"]
-        # Windows is a bit behind on Python version availability in Github
-        exclude:
-          - os: windows-latest
-            python-version: "3.11.11"
-          - os: windows-latest
-            python-version: "3.10.16"
-        include:
-          - os: windows-latest
-            python-version: "3.11.9"
-          - os: windows-latest
-            python-version: "3.10.11"
+        python-version: ["3.10", "3.11", "3.12", "3.13"]
     runs-on: ${{ matrix.os }}
 
     # Service containers to run with `runner-job`
@@ -88,8 +77,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        # Note: py310, py311 versions chosen due to available arm64 darwin builds.
-        python-version: ["3.10.11", "3.11.9", "3.12.8", "3.13.1"]
+        python-version: ["3.10", "3.11", "3.12", "3.13"]
     runs-on: macos-latest
     steps:
       - uses: actions/checkout@v4

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.13

+ 2 - 3
CONTRIBUTING.md

@@ -23,7 +23,7 @@ cd reflex
 **3. Install your local Reflex build:**
 
 ```bash
-uv sync --python 3.13
+uv sync
 ```
 
 **4. Now create an examples folder so you can test the local Python build in this repository.**
@@ -86,10 +86,9 @@ uv run ruff format .
 ```
 
 Consider installing git pre-commit hooks so Ruff, Pyright, Darglint and `make_pyi` will run automatically before each commit.
-Note that pre-commit will only be installed when you use a Python version >= 3.10.
 
 ```bash
-pre-commit install
+uv run pre-commit install
 ```
 
 That's it you can now submit your PR. Thanks for contributing to Reflex!

+ 5 - 5
pyproject.toml

@@ -1,6 +1,6 @@
 [project]
 name = "reflex"
-version = "0.7.9dev1"
+version = "0.7.10dev1"
 description = "Web apps in pure Python."
 license = { text = "Apache-2.0" }
 authors = [
@@ -23,7 +23,6 @@ dependencies = [
   "alembic >=1.15.2,<2.0",
   "fastapi >=0.115.0",
   "granian[reload] >=2.2.5",
-  "gunicorn >=23.0.0,<24.0.0",
   "httpx >=0.28.0,<1.0",
   "jinja2 >=3.1.2,<4.0",
   "packaging >=24.2,<26",
@@ -33,12 +32,11 @@ dependencies = [
   "python-socketio >=5.12.0,<6.0",
   "python-multipart >=0.0.20,<1.0",
   "redis >=5.2.1,<6.0",
-  "reflex-hosting-cli >=0.1.38",
+  "reflex-hosting-cli >=0.1.43",
   "rich >=13,<15",
   "sqlmodel >=0.0.24,<0.1",
-  "typer >=0.15.2,<1.0",
+  "click >=8",
   "typing_extensions >=4.13.0",
-  "uvicorn >=0.34.0",
   "wrapt >=1.17.0,<2.0",
 ]
 classifiers = [
@@ -166,4 +164,6 @@ dev = [
   "ruff >=0.11",
   "selenium >=4.31",
   "starlette-admin >=0.14",
+  "uvicorn >=0.34.0",
+
 ]

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

@@ -243,13 +243,13 @@ export const applyEvent = async (event, socket) => {
   if (event.name == "_set_focus") {
     const ref =
       event.payload.ref in refs ? refs[event.payload.ref] : event.payload.ref;
-    const focus = ref?.current?.focus;
-    if (focus === undefined) {
+    const current = ref?.current;
+    if (current === undefined || current?.focus === undefined) {
       console.error(
         `No element found for ref ${event.payload.ref} in _set_focus`,
       );
     } else {
-      focus();
+      current.focus();
     }
     return false;
   }

+ 135 - 62
reflex/app.py

@@ -13,22 +13,26 @@ import io
 import json
 import sys
 import traceback
-from collections.abc import AsyncIterator, Callable, Coroutine, MutableMapping
+from collections.abc import AsyncIterator, Callable, Coroutine, Sequence
 from datetime import datetime
 from pathlib import Path
 from timeit import default_timer as timer
 from types import SimpleNamespace
 from typing import TYPE_CHECKING, Any, BinaryIO, ParamSpec, get_args, get_type_hints
 
-from fastapi import FastAPI, HTTPException, Request
-from fastapi import UploadFile as FastAPIUploadFile
-from fastapi.middleware import cors
-from fastapi.responses import JSONResponse, StreamingResponse
-from fastapi.staticfiles import StaticFiles
+from fastapi import FastAPI
 from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn
-from socketio import ASGIApp, AsyncNamespace, AsyncServer
+from socketio import ASGIApp as EngineIOApp
+from socketio import AsyncNamespace, AsyncServer
+from starlette.applications import Starlette
 from starlette.datastructures import Headers
 from starlette.datastructures import UploadFile as StarletteUploadFile
+from starlette.exceptions import HTTPException
+from starlette.middleware import cors
+from starlette.requests import Request
+from starlette.responses import JSONResponse, Response, StreamingResponse
+from starlette.staticfiles import StaticFiles
+from typing_extensions import deprecated
 
 from reflex import constants
 from reflex.admin import AdminDash
@@ -45,6 +49,7 @@ from reflex.components.base.error_boundary import ErrorBoundary
 from reflex.components.base.fragment import Fragment
 from reflex.components.base.strict_mode import StrictMode
 from reflex.components.component import (
+    CUSTOM_COMPONENTS,
     Component,
     ComponentStyle,
     evaluate_style_namespaces,
@@ -101,6 +106,7 @@ from reflex.utils import (
 )
 from reflex.utils.exec import get_compile_context, is_prod_mode, is_testing_env
 from reflex.utils.imports import ImportVar
+from reflex.utils.types import ASGIApp, Message, Receive, Scope, Send
 
 if TYPE_CHECKING:
     from reflex.vars import Var
@@ -391,7 +397,7 @@ class App(MiddlewareMixin, LifespanMixin):
     _stateful_pages: dict[str, None] = dataclasses.field(default_factory=dict)
 
     # The backend API object.
-    _api: FastAPI | None = None
+    _api: Starlette | None = None
 
     # The state class to use for the app.
     _state: type[BaseState] | None = None
@@ -426,14 +432,34 @@ class App(MiddlewareMixin, LifespanMixin):
     # Put the toast provider in the app wrap.
     toaster: Component | None = dataclasses.field(default_factory=toast.provider)
 
+    # Transform the ASGI app before running it.
+    api_transformer: (
+        Sequence[Callable[[ASGIApp], ASGIApp] | Starlette]
+        | Callable[[ASGIApp], ASGIApp]
+        | Starlette
+        | None
+    ) = None
+
+    # FastAPI app for compatibility with FastAPI.
+    _cached_fastapi_app: FastAPI | None = None
+
     @property
-    def api(self) -> FastAPI | None:
+    @deprecated("Use `api_transformer=your_fastapi_app` instead.")
+    def api(self) -> FastAPI:
         """Get the backend api.
 
         Returns:
             The backend api.
         """
-        return self._api
+        if self._cached_fastapi_app is None:
+            self._cached_fastapi_app = FastAPI()
+        console.deprecate(
+            feature_name="App.api",
+            reason="Set `api_transformer=your_fastapi_app` instead.",
+            deprecation_version="0.7.9",
+            removal_version="0.8.0",
+        )
+        return self._cached_fastapi_app
 
     @property
     def event_namespace(self) -> EventNamespace | None:
@@ -465,8 +491,8 @@ class App(MiddlewareMixin, LifespanMixin):
             set_breakpoints(self.style.pop("breakpoints"))
 
         # Set up the API.
-        self._api = FastAPI(lifespan=self._run_lifespan_tasks)
-        self._add_cors()
+        self._api = Starlette(lifespan=self._run_lifespan_tasks)
+        App._add_cors(self._api)
         self._add_default_endpoints()
 
         for clz in App.__mro__:
@@ -531,7 +557,7 @@ class App(MiddlewareMixin, LifespanMixin):
             )
 
         # Create the socket app. Note event endpoint constant replaces the default 'socket.io' path.
-        socket_app = ASGIApp(self.sio, socketio_path="")
+        socket_app = EngineIOApp(self.sio, socketio_path="")
         namespace = config.get_event_namespace()
 
         # Create the event namespace and attach the main app. Not related to any paths.
@@ -540,18 +566,16 @@ class App(MiddlewareMixin, LifespanMixin):
         # Register the event namespace with the socket.
         self.sio.register_namespace(self.event_namespace)
         # Mount the socket app with the API.
-        if self.api:
+        if self._api:
 
             class HeaderMiddleware:
                 def __init__(self, app: ASGIApp):
                     self.app = app
 
-                async def __call__(
-                    self, scope: MutableMapping[str, Any], receive: Any, send: Callable
-                ):
+                async def __call__(self, scope: Scope, receive: Receive, send: Send):
                     original_send = send
 
-                    async def modified_send(message: dict):
+                    async def modified_send(message: Message):
                         if message["type"] == "websocket.accept":
                             if scope.get("subprotocols"):
                                 # The following *does* say "subprotocol" instead of "subprotocols", intentionally.
@@ -570,7 +594,7 @@ class App(MiddlewareMixin, LifespanMixin):
                     return await self.app(scope, receive, modified_send)
 
             socket_app_with_headers = HeaderMiddleware(socket_app)
-            self.api.mount(str(constants.Endpoint.EVENT), socket_app_with_headers)
+            self._api.mount(str(constants.Endpoint.EVENT), socket_app_with_headers)
 
         # Check the exception handlers
         self._validate_exception_handlers()
@@ -583,7 +607,7 @@ class App(MiddlewareMixin, LifespanMixin):
         """
         return f"<App state={self._state.__name__ if self._state else None}>"
 
-    def __call__(self) -> FastAPI:
+    def __call__(self) -> ASGIApp:
         """Run the backend api instance.
 
         Raises:
@@ -592,9 +616,6 @@ class App(MiddlewareMixin, LifespanMixin):
         Returns:
             The backend api.
         """
-        if not self.api:
-            raise ValueError("The app has not been initialized.")
-
         # For py3.9 compatibility when redis is used, we MUST add any decorator pages
         # before compiling the app in a thread to avoid event loop error (REF-2172).
         self._apply_decorated_pages()
@@ -608,34 +629,71 @@ class App(MiddlewareMixin, LifespanMixin):
             return f.result()
 
         compile_future.add_done_callback(callback)
-        # Wait for the compile to finish in prod mode to ensure all optional endpoints are mounted.
-        if is_prod_mode():
-            compile_future.result()
+        # Wait for the compile to finish to ensure all optional endpoints are mounted.
+        compile_future.result()
 
-        return self.api
+        if not self._api:
+            raise ValueError("The app has not been initialized.")
+        if self._cached_fastapi_app is not None:
+            asgi_app = self._cached_fastapi_app
+            asgi_app.mount("", self._api)
+            App._add_cors(asgi_app)
+        else:
+            asgi_app = self._api
+
+        if self.api_transformer is not None:
+            api_transformers: Sequence[Starlette | Callable[[ASGIApp], ASGIApp]] = (
+                [self.api_transformer]
+                if not isinstance(self.api_transformer, Sequence)
+                else self.api_transformer
+            )
+
+            for api_transformer in api_transformers:
+                if isinstance(api_transformer, Starlette):
+                    # Mount the api to the fastapi app.
+                    App._add_cors(api_transformer)
+                    api_transformer.mount("", asgi_app)
+                    asgi_app = api_transformer
+                else:
+                    # Transform the asgi app.
+                    asgi_app = api_transformer(asgi_app)
+
+        return asgi_app
 
     def _add_default_endpoints(self):
         """Add default api endpoints (ping)."""
         # To test the server.
-        if not self.api:
+        if not self._api:
             return
 
-        self.api.get(str(constants.Endpoint.PING))(ping)
-        self.api.get(str(constants.Endpoint.HEALTH))(health)
+        self._api.add_route(
+            str(constants.Endpoint.PING),
+            ping,
+            methods=["GET"],
+        )
+        self._api.add_route(
+            str(constants.Endpoint.HEALTH),
+            health,
+            methods=["GET"],
+        )
 
     def _add_optional_endpoints(self):
         """Add optional api endpoints (_upload)."""
-        if not self.api:
+        if not self._api:
             return
         upload_is_used_marker = (
             prerequisites.get_backend_dir() / constants.Dirs.UPLOAD_IS_USED
         )
         if Upload.is_used or upload_is_used_marker.exists():
             # To upload files.
-            self.api.post(str(constants.Endpoint.UPLOAD))(upload(self))
+            self._api.add_route(
+                str(constants.Endpoint.UPLOAD),
+                upload(self),
+                methods=["POST"],
+            )
 
             # To access uploaded files.
-            self.api.mount(
+            self._api.mount(
                 str(constants.Endpoint.UPLOAD),
                 StaticFiles(directory=get_upload_dir()),
                 name="uploaded_files",
@@ -644,17 +702,22 @@ class App(MiddlewareMixin, LifespanMixin):
             upload_is_used_marker.parent.mkdir(parents=True, exist_ok=True)
             upload_is_used_marker.touch()
         if codespaces.is_running_in_codespaces():
-            self.api.get(str(constants.Endpoint.AUTH_CODESPACE))(
-                codespaces.auth_codespace
+            self._api.add_route(
+                str(constants.Endpoint.AUTH_CODESPACE),
+                codespaces.auth_codespace,
+                methods=["GET"],
             )
         if environment.REFLEX_ADD_ALL_ROUTES_ENDPOINT.get():
             self.add_all_routes_endpoint()
 
-    def _add_cors(self):
-        """Add CORS middleware to the app."""
-        if not self.api:
-            return
-        self.api.add_middleware(
+    @staticmethod
+    def _add_cors(api: Starlette):
+        """Add CORS middleware to the app.
+
+        Args:
+            api: The Starlette app to add CORS middleware to.
+        """
+        api.add_middleware(
             cors.CORSMiddleware,
             allow_credentials=True,
             allow_methods=["*"],
@@ -919,7 +982,7 @@ class App(MiddlewareMixin, LifespanMixin):
             return
 
         # Get the admin dash.
-        if not self.api:
+        if not self._api:
             return
 
         admin_dash = self.admin_dash
@@ -940,7 +1003,7 @@ class App(MiddlewareMixin, LifespanMixin):
                 view = admin_dash.view_overrides.get(model, ModelView)
                 admin.add_view(view(model))
 
-            admin.mount_to(self.api)
+            admin.mount_to(self._api)
 
     def _get_frontend_packages(self, imports: dict[str, set[ImportVar]]):
         """Gets the frontend packages to be installed and filters out the unnecessary ones.
@@ -1054,7 +1117,6 @@ class App(MiddlewareMixin, LifespanMixin):
         # Add the @rx.page decorated pages to collect on_load events.
         for render, kwargs in DECORATED_PAGES[app_name]:
             self.add_page(render, **kwargs)
-        DECORATED_PAGES[app_name].clear()
 
     def _validate_var_dependencies(self, state: type[BaseState] | None = None) -> None:
         """Validate the dependencies of the vars in the app.
@@ -1222,9 +1284,8 @@ class App(MiddlewareMixin, LifespanMixin):
 
         progress.advance(task)
 
-        # Track imports and custom components found.
+        # Track imports found.
         all_imports = {}
-        custom_components = set()
 
         # This has to happen before compiling stateful components as that
         # prevents recursive functions from reaching all components.
@@ -1235,9 +1296,6 @@ class App(MiddlewareMixin, LifespanMixin):
             # Add the app wrappers from this component.
             app_wrappers.update(component._get_all_app_wrap_components())
 
-            # Add the custom components from the page to the set.
-            custom_components |= component._get_all_custom_components()
-
         if (toaster := self.toaster) is not None:
             from reflex.components.component import memo
 
@@ -1255,9 +1313,6 @@ class App(MiddlewareMixin, LifespanMixin):
             if component is not None:
                 app_wrappers[key] = component
 
-        for component in app_wrappers.values():
-            custom_components |= component._get_all_custom_components()
-
         if self.error_boundary:
             from reflex.compiler.compiler import into_component
 
@@ -1392,7 +1447,7 @@ class App(MiddlewareMixin, LifespanMixin):
             custom_components_output,
             custom_components_result,
             custom_components_imports,
-        ) = compiler.compile_components(custom_components)
+        ) = compiler.compile_components(set(CUSTOM_COMPONENTS.values()))
         compile_results.append((custom_components_output, custom_components_result))
         all_imports.update(custom_components_imports)
 
@@ -1443,12 +1498,15 @@ class App(MiddlewareMixin, LifespanMixin):
 
     def add_all_routes_endpoint(self):
         """Add an endpoint to the app that returns all the routes."""
-        if not self.api:
+        if not self._api:
             return
 
-        @self.api.get(str(constants.Endpoint.ALL_ROUTES))
-        async def all_routes():
-            return list(self._unevaluated_pages.keys())
+        async def all_routes(_request: Request) -> Response:
+            return JSONResponse(list(self._unevaluated_pages.keys()))
+
+        self._api.add_route(
+            str(constants.Endpoint.ALL_ROUTES), all_routes, methods=["GET"]
+        )
 
     @contextlib.asynccontextmanager
     async def modify_state(self, token: str) -> AsyncIterator[BaseState]:
@@ -1703,18 +1761,24 @@ async def process(
         raise
 
 
-async def ping() -> str:
+async def ping(_request: Request) -> Response:
     """Test API endpoint.
 
+    Args:
+        _request: The Starlette request object.
+
     Returns:
         The response.
     """
-    return "pong"
+    return JSONResponse("pong")
 
 
-async def health() -> JSONResponse:
+async def health(_request: Request) -> JSONResponse:
     """Health check endpoint to assess the status of the database and Redis services.
 
+    Args:
+        _request: The Starlette request object.
+
     Returns:
         JSONResponse: A JSON object with the health status:
             - "status" (bool): Overall health, True if all checks pass.
@@ -1756,12 +1820,11 @@ def upload(app: App):
         The upload function.
     """
 
-    async def upload_file(request: Request, files: list[FastAPIUploadFile]):
+    async def upload_file(request: Request):
         """Upload a file.
 
         Args:
-            request: The FastAPI request object.
-            files: The file(s) to upload.
+            request: The Starlette request object.
 
         Returns:
             StreamingResponse yielding newline-delimited JSON of StateUpdate
@@ -1774,6 +1837,12 @@ def upload(app: App):
         """
         from reflex.utils.exceptions import UploadTypeError, UploadValueError
 
+        # Get the files from the request.
+        files = await request.form()
+        files = files.getlist("files")
+        if not files:
+            raise UploadValueError("No files were uploaded.")
+
         token = request.headers.get("reflex-client-token")
         handler = request.headers.get("reflex-event-handler")
 
@@ -1826,6 +1895,10 @@ def upload(app: App):
         # event is handled.
         file_copies = []
         for file in files:
+            if not isinstance(file, StarletteUploadFile):
+                raise UploadValueError(
+                    "Uploaded file is not an UploadFile." + str(file)
+                )
             content_copy = io.BytesIO()
             content_copy.write(await file.read())
             content_copy.seek(0)

+ 2 - 2
reflex/app_mixins/lifespan.py

@@ -9,7 +9,7 @@ import functools
 import inspect
 from collections.abc import Callable, Coroutine
 
-from fastapi import FastAPI
+from starlette.applications import Starlette
 
 from reflex.utils import console
 from reflex.utils.exceptions import InvalidLifespanTaskTypeError
@@ -27,7 +27,7 @@ class LifespanMixin(AppMixin):
     )
 
     @contextlib.asynccontextmanager
-    async def _run_lifespan_tasks(self, app: FastAPI):
+    async def _run_lifespan_tasks(self, app: Starlette):
         running_tasks = []
         try:
             async with contextlib.AsyncExitStack() as stack:

+ 1 - 4
reflex/compiler/compiler.py

@@ -56,7 +56,7 @@ def _normalize_library_name(lib: str) -> str:
     """
     if lib == "react":
         return "React"
-    return lib.replace("@", "").replace("/", "_").replace("-", "_")
+    return lib.replace("$/", "").replace("@", "").replace("/", "_").replace("-", "_")
 
 
 def _compile_app(app_root: Component) -> str:
@@ -72,9 +72,6 @@ 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"),
     ]
 
     return templates.APP_ROOT.render(

+ 39 - 57
reflex/components/component.py

@@ -1647,32 +1647,6 @@ class Component(BaseComponent, ABC):
 
         return refs
 
-    def _get_all_custom_components(
-        self, seen: set[str] | None = None
-    ) -> set[CustomComponent]:
-        """Get all the custom components used by the component.
-
-        Args:
-            seen: The tags of the components that have already been seen.
-
-        Returns:
-            The set of custom components.
-        """
-        custom_components = set()
-
-        # Store the seen components in a set to avoid infinite recursion.
-        if seen is None:
-            seen = set()
-        for child in self.children:
-            # Skip BaseComponent and StatefulComponent children.
-            if not isinstance(child, Component):
-                continue
-            custom_components |= child._get_all_custom_components(seen=seen)
-        for component in self._get_components_in_props():
-            if isinstance(component, Component) and component.tag is not None:
-                custom_components |= component._get_all_custom_components(seen=seen)
-        return custom_components
-
     @property
     def import_var(self):
         """The tag to import.
@@ -1857,37 +1831,6 @@ class CustomComponent(Component):
         """
         return set()
 
-    def _get_all_custom_components(
-        self, seen: set[str] | None = None
-    ) -> set[CustomComponent]:
-        """Get all the custom components used by the component.
-
-        Args:
-            seen: The tags of the components that have already been seen.
-
-        Raises:
-            ValueError: If the tag is not set.
-
-        Returns:
-            The set of custom components.
-        """
-        if self.tag is None:
-            raise ValueError("The tag must be set.")
-
-        # Store the seen components in a set to avoid infinite recursion.
-        if seen is None:
-            seen = set()
-        custom_components = {self} | super()._get_all_custom_components(seen=seen)
-
-        # Avoid adding the same component twice.
-        if self.tag not in seen:
-            seen.add(self.tag)
-            custom_components |= self.get_component(self)._get_all_custom_components(
-                seen=seen
-            )
-
-        return custom_components
-
     @staticmethod
     def _get_event_spec_from_args_spec(name: str, event: EventChain) -> Callable:
         """Get the event spec from the args spec.
@@ -1951,6 +1894,42 @@ class CustomComponent(Component):
         return self.component_fn(*self.get_prop_vars())
 
 
+CUSTOM_COMPONENTS: dict[str, CustomComponent] = {}
+
+
+def _register_custom_component(
+    component_fn: Callable[..., Component],
+):
+    """Register a custom component to be compiled.
+
+    Args:
+        component_fn: The function that creates the component.
+
+    Raises:
+        TypeError: If the tag name cannot be determined.
+    """
+    dummy_props = {
+        prop: (
+            Var(
+                "",
+                _var_type=annotation,
+            )
+            if not types.safe_issubclass(annotation, EventHandler)
+            else EventSpec(handler=EventHandler(fn=lambda: []))
+        )
+        for prop, annotation in typing.get_type_hints(component_fn).items()
+        if prop != "return"
+    }
+    dummy_component = CustomComponent._create(
+        children=[],
+        component_fn=component_fn,
+        **dummy_props,
+    )
+    if dummy_component.tag is None:
+        raise TypeError(f"Could not determine the tag name for {component_fn!r}")
+    CUSTOM_COMPONENTS[dummy_component.tag] = dummy_component
+
+
 def custom_component(
     component_fn: Callable[..., Component],
 ) -> Callable[..., CustomComponent]:
@@ -1971,6 +1950,9 @@ def custom_component(
             children=list(children), component_fn=component_fn, **props
         )
 
+    # Register this component so it can be compiled.
+    _register_custom_component(component_fn)
+
     return wrapper
 
 

+ 8 - 0
reflex/components/core/upload.py

@@ -273,6 +273,14 @@ class Upload(MemoizationLeaf):
             elif isinstance(on_drop, Callable):
                 # Call the lambda to get the event chain.
                 on_drop = call_event_fn(on_drop, _on_drop_spec)
+            if isinstance(on_drop, EventSpec):
+                # Update the provided args for direct use with on_drop.
+                on_drop = on_drop.with_args(
+                    args=tuple(
+                        cls._update_arg_tuple_for_on_drop(arg_value)
+                        for arg_value in on_drop.args
+                    ),
+                )
             upload_props["on_drop"] = on_drop
 
         input_props_unique_name = get_unique_variable_name()

+ 9 - 1
reflex/components/dynamic.py

@@ -26,7 +26,15 @@ def get_cdn_url(lib: str) -> str:
     return f"https://cdn.jsdelivr.net/npm/{lib}" + "/+esm"
 
 
-bundled_libraries = {"react", "@radix-ui/themes", "@emotion/react", "next/link"}
+bundled_libraries = {
+    "react",
+    "@radix-ui/themes",
+    "@emotion/react",
+    "next/link",
+    f"$/{constants.Dirs.UTILS}/context",
+    f"$/{constants.Dirs.UTILS}/state",
+    f"$/{constants.Dirs.UTILS}/components",
+}
 
 
 def bundle_library(component: Union["Component", str]):

+ 0 - 21
reflex/components/markdown/markdown.py

@@ -192,27 +192,6 @@ class Markdown(Component):
             **props,
         )
 
-    def _get_all_custom_components(
-        self, seen: set[str] | None = None
-    ) -> set[CustomComponent]:
-        """Get all the custom components used by the component.
-
-        Args:
-            seen: The tags of the components that have already been seen.
-
-        Returns:
-            The set of custom components.
-        """
-        custom_components = super()._get_all_custom_components(seen=seen)
-
-        # Get the custom components for each tag.
-        for component in self.component_map.values():
-            custom_components |= component(_MOCK_ARG)._get_all_custom_components(
-                seen=seen
-            )
-
-        return custom_components
-
     def add_imports(self) -> ImportDict | list[ImportDict]:
         """Add imports for the markdown component.
 

+ 1 - 1
reflex/components/radix/primitives/accordion.py

@@ -54,7 +54,7 @@ def _inherited_variant_selector(
 class AccordionComponent(RadixPrimitiveComponent):
     """Base class for all @radix-ui/accordion components."""
 
-    library = "@radix-ui/react-accordion@^1.2.3"
+    library = "@radix-ui/react-accordion@^1.2.8"
 
     # The color scheme of the component.
     color_scheme: Var[LiteralAccentColor]

+ 1 - 1
reflex/components/radix/primitives/form.py

@@ -17,7 +17,7 @@ from .base import RadixPrimitiveComponentWithClassName
 class FormComponent(RadixPrimitiveComponentWithClassName):
     """Base class for all @radix-ui/react-form components."""
 
-    library = "@radix-ui/react-form@^0.1.2"
+    library = "@radix-ui/react-form@^0.1.4"
 
 
 class FormRoot(FormComponent, HTMLForm):

+ 1 - 1
reflex/components/radix/primitives/progress.py

@@ -15,7 +15,7 @@ from reflex.vars.base import Var
 class ProgressComponent(RadixPrimitiveComponentWithClassName):
     """A Progress component."""
 
-    library = "@radix-ui/react-progress@^1.1.2"
+    library = "@radix-ui/react-progress@^1.1.4"
 
 
 class ProgressRoot(ProgressComponent):

+ 1 - 1
reflex/components/radix/primitives/slider.py

@@ -17,7 +17,7 @@ LiteralSliderDir = Literal["ltr", "rtl"]
 class SliderComponent(RadixPrimitiveComponentWithClassName):
     """Base class for all @radix-ui/react-slider components."""
 
-    library = "@radix-ui/react-slider@^1.2.3"
+    library = "@radix-ui/react-slider@^1.3.2"
 
 
 def on_value_event_spec(

+ 2 - 2
reflex/components/recharts/recharts.py

@@ -8,7 +8,7 @@ from reflex.components.component import Component, MemoizationLeaf, NoSSRCompone
 class Recharts(Component):
     """A component that wraps a recharts lib."""
 
-    library = "recharts@2.15.1"
+    library = "recharts@2.15.3"
 
     def _get_style(self) -> dict:
         return {"wrapperStyle": self.style}
@@ -17,7 +17,7 @@ class Recharts(Component):
 class RechartsCharts(NoSSRComponent, MemoizationLeaf):
     """A component that wraps a recharts lib."""
 
-    library = "recharts@2.15.1"
+    library = "recharts@2.15.3"
 
 
 LiteralAnimationEasing = Literal["ease", "ease-in", "ease-out", "ease-in-out", "linear"]

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

@@ -172,7 +172,7 @@ class ToastProps(PropsBase, NoExtrasAllowedProps):
 class Toaster(Component):
     """A Toaster Component for displaying toast notifications."""
 
-    library: str | None = "sonner@2.0.1"
+    library: str | None = "sonner@2.0.3"
 
     tag = "Toaster"
 

+ 4 - 4
reflex/config.py

@@ -393,7 +393,7 @@ class EnvVar(Generic[T]):
             The environment variable value.
         """
         env_value = os.getenv(self.name, None)
-        if env_value is not None:
+        if env_value and env_value.strip():
             return self.interpret(env_value)
         return None
 
@@ -403,7 +403,7 @@ class EnvVar(Generic[T]):
         Returns:
             True if the environment variable is set.
         """
-        return self.name in os.environ
+        return bool(os.getenv(self.name, "").strip())
 
     def get(self) -> T:
         """Get the interpreted environment variable value or the default value if not set.
@@ -907,7 +907,7 @@ class Config(Base):
         # Set the log level for this process
         env_loglevel = os.environ.get("LOGLEVEL")
         if env_loglevel is not None:
-            env_loglevel = LogLevel(env_loglevel)
+            env_loglevel = LogLevel(env_loglevel.lower())
         if env_loglevel or self.loglevel != LogLevel.DEFAULT:
             console.set_log_level(env_loglevel or self.loglevel)
 
@@ -980,7 +980,7 @@ class Config(Base):
             env_var = os.environ.get(key.upper())
 
             # If the env var is set, override the config value.
-            if env_var is not None:
+            if env_var and env_var.strip():
                 # Interpret the value.
                 value = interpret_env_var_value(
                     env_var, true_type_for_pydantic_field(field), field.name

+ 2 - 0
reflex/constants/__init__.py

@@ -41,6 +41,7 @@ from .config import (
     DefaultPorts,
     Expiration,
     GitIgnore,
+    PyprojectToml,
     RequirementsTxt,
 )
 from .custom_components import CustomComponents
@@ -105,6 +106,7 @@ __all__ = [
     "Page404",
     "PageNames",
     "Ping",
+    "PyprojectToml",
     "Reflex",
     "RequirementsTxt",
     "RouteArgType",

+ 21 - 0
reflex/constants/base.py

@@ -7,6 +7,7 @@ from enum import Enum
 from importlib import metadata
 from pathlib import Path
 from types import SimpleNamespace
+from typing import Literal
 
 from platformdirs import PlatformDirs
 
@@ -219,6 +220,9 @@ class ColorMode(SimpleNamespace):
     SET = "setColorMode"
 
 
+LITERAL_ENV = Literal["dev", "prod"]
+
+
 # Env modes
 class Env(str, Enum):
     """The environment modes."""
@@ -238,6 +242,23 @@ class LogLevel(str, Enum):
     ERROR = "error"
     CRITICAL = "critical"
 
+    @classmethod
+    def from_string(cls, level: str | None) -> LogLevel | None:
+        """Convert a string to a log level.
+
+        Args:
+            level: The log level as a string.
+
+        Returns:
+            The log level.
+        """
+        if not level:
+            return None
+        try:
+            return LogLevel[level.upper()]
+        except KeyError:
+            return None
+
     def __le__(self, other: LogLevel) -> bool:
         """Compare log levels.
 

+ 7 - 0
reflex/constants/config.py

@@ -49,6 +49,13 @@ class GitIgnore(SimpleNamespace):
     }
 
 
+class PyprojectToml(SimpleNamespace):
+    """Pyproject.toml constants."""
+
+    # The pyproject.toml file.
+    FILE = "pyproject.toml"
+
+
 class RequirementsTxt(SimpleNamespace):
     """Requirements.txt constants."""
 

+ 6 - 6
reflex/constants/installer.py

@@ -14,7 +14,7 @@ class Bun(SimpleNamespace):
     """Bun constants."""
 
     # The Bun version.
-    VERSION = "1.2.8"
+    VERSION = "1.2.10"
 
     # Min Bun Version
     MIN_VERSION = "1.2.8"
@@ -75,7 +75,7 @@ fetch-retries=0
 
 
 def _determine_nextjs_version() -> str:
-    default_version = "15.3.0"
+    default_version = "15.3.1"
     if (version := os.getenv("NEXTJS_VERSION")) and version != default_version:
         from reflex.utils import console
 
@@ -101,13 +101,13 @@ class PackageJson(SimpleNamespace):
 
     DEPENDENCIES = {
         "@emotion/react": "11.14.0",
-        "axios": "1.8.3",
+        "axios": "1.8.4",
         "json5": "2.2.3",
         "next": _determine_nextjs_version(),
         "next-sitemap": "4.2.3",
         "next-themes": "0.4.6",
-        "react": "19.0.0",
-        "react-dom": "19.0.0",
+        "react": "19.1.0",
+        "react-dom": "19.1.0",
         "react-focus-lock": "2.13.6",
         "socket.io-client": "4.8.1",
         "universal-cookie": "7.2.2",
@@ -119,5 +119,5 @@ class PackageJson(SimpleNamespace):
     }
     OVERRIDES = {
         # This should always match the `react` version in DEPENDENCIES for recharts compatibility.
-        "react-is": "19.0.0"
+        "react-is": "19.1.0"
     }

+ 67 - 64
reflex/custom_components/custom_components.py

@@ -9,17 +9,46 @@ import sys
 from collections import namedtuple
 from contextlib import contextmanager
 from pathlib import Path
+from typing import Any
 
+import click
 import httpx
-import typer
 
 from reflex import constants
-from reflex.config import get_config
 from reflex.constants import CustomComponents
 from reflex.utils import console
 
-custom_components_cli = typer.Typer()
 
+def set_loglevel(ctx: Any, self: Any, value: str | None):
+    """Set the log level.
+
+    Args:
+        ctx: The click context.
+        self: The click command.
+        value: The log level to set.
+    """
+    if value is not None:
+        loglevel = constants.LogLevel.from_string(value)
+        console.set_log_level(loglevel)
+
+
+@click.group
+def custom_components_cli():
+    """CLI for creating custom components."""
+    pass
+
+
+loglevel_option = click.option(
+    "--loglevel",
+    type=click.Choice(
+        [loglevel.value for loglevel in constants.LogLevel],
+        case_sensitive=False,
+    ),
+    callback=set_loglevel,
+    is_eager=True,
+    expose_value=False,
+    help="The log level to use.",
+)
 
 POST_CUSTOM_COMPONENTS_GALLERY_TIMEOUT = 15
 
@@ -163,13 +192,13 @@ def _get_default_library_name_parts() -> list[str]:
             console.error(
                 f"Based on current directory name {current_dir_name}, the library name is {constants.Reflex.MODULE_NAME}. This package already exists. Please use --library-name to specify a different name."
             )
-            raise typer.Exit(code=1)
+            raise click.exceptions.Exit(code=1)
     if not parts:
         # The folder likely has a name not suitable for python paths.
         console.error(
             f"Could not find a valid library name based on the current directory: got {current_dir_name}."
         )
-        raise typer.Exit(code=1)
+        raise click.exceptions.Exit(code=1)
     return parts
 
 
@@ -205,7 +234,7 @@ def _validate_library_name(library_name: str | None) -> NameVariants:
         console.error(
             f"Please use only alphanumeric characters or dashes: got {library_name}"
         )
-        raise typer.Exit(code=1)
+        raise click.exceptions.Exit(code=1)
 
     # If not specified, use the current directory name to form the module name.
     name_parts = (
@@ -277,36 +306,35 @@ def _populate_custom_component_project(name_variants: NameVariants):
 
 
 @custom_components_cli.command(name="init")
+@click.option(
+    "--library-name",
+    default=None,
+    help="The name of your library. On PyPI, package will be published as `reflex-{library-name}`.",
+)
+@click.option(
+    "--install/--no-install",
+    default=True,
+    help="Whether to install package from this local custom component in editable mode.",
+)
+@loglevel_option
 def init(
-    library_name: str | None = typer.Option(
-        None,
-        help="The name of your library. On PyPI, package will be published as `reflex-{library-name}`.",
-    ),
-    install: bool = typer.Option(
-        True,
-        help="Whether to install package from this local custom component in editable mode.",
-    ),
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
+    library_name: str | None,
+    install: bool,
 ):
     """Initialize a custom component.
 
     Args:
         library_name: The name of the library.
         install: Whether to install package from this local custom component in editable mode.
-        loglevel: The log level to use.
 
     Raises:
         Exit: If the pyproject.toml already exists.
     """
     from reflex.utils import exec, prerequisites
 
-    console.set_log_level(loglevel or get_config().loglevel)
-
     if CustomComponents.PYPROJECT_TOML.exists():
         console.error(f"A {CustomComponents.PYPROJECT_TOML} already exists. Aborting.")
-        typer.Exit(code=1)
+        click.exceptions.Exit(code=1)
 
     # Show system info.
     exec.output_system_info()
@@ -331,7 +359,7 @@ def init(
         if _pip_install_on_demand(package_name=".", install_args=["-e"]):
             console.info(f"Package {package_name} installed!")
         else:
-            raise typer.Exit(code=1)
+            raise click.exceptions.Exit(code=1)
 
     console.print("[bold]Custom component initialized successfully!")
     console.rule("[bold]Project Summary")
@@ -424,21 +452,13 @@ def _run_build():
     if _run_commands_in_subprocess(cmds):
         console.info("Custom component built successfully!")
     else:
-        raise typer.Exit(code=1)
+        raise click.exceptions.Exit(code=1)
 
 
 @custom_components_cli.command(name="build")
-def build(
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-):
-    """Build a custom component. Must be run from the project root directory where the pyproject.toml is.
-
-    Args:
-        loglevel: The log level to use.
-    """
-    console.set_log_level(loglevel or get_config().loglevel)
+@loglevel_option
+def build():
+    """Build a custom component. Must be run from the project root directory where the pyproject.toml is."""
     _run_build()
 
 
@@ -453,7 +473,7 @@ def publish():
         "The publish command is deprecated. You can use `reflex component build` followed by `twine upload` or a similar publishing command to publish your custom component."
         "\nIf you want to share your custom component with the Reflex community, please use `reflex component share`."
     )
-    raise typer.Exit(code=1)
+    raise click.exceptions.Exit(code=1)
 
 
 def _collect_details_for_gallery():
@@ -472,7 +492,7 @@ def _collect_details_for_gallery():
         console.error(
             "Unable to authenticate with Reflex backend services. Make sure you are logged in."
         )
-        raise typer.Exit(code=1)
+        raise click.exceptions.Exit(code=1)
 
     console.rule("[bold]Custom Component Information")
     params = {}
@@ -502,11 +522,11 @@ def _collect_details_for_gallery():
             console.error(
                 f"{package_name} is owned by another user. Unable to update the information for it."
             )
-            raise typer.Exit(code=1)
+            raise click.exceptions.Exit(code=1)
         response.raise_for_status()
     except httpx.HTTPError as he:
         console.error(f"Unable to complete request due to {he}.")
-        raise typer.Exit(code=1) from he
+        raise click.exceptions.Exit(code=1) from he
 
     files = []
     if (image_file_and_extension := _get_file_from_prompt_in_loop()) is not None:
@@ -541,7 +561,7 @@ def _collect_details_for_gallery():
 
     except httpx.HTTPError as he:
         console.error(f"Unable to complete request due to {he}.")
-        raise typer.Exit(code=1) from he
+        raise click.exceptions.Exit(code=1) from he
 
     console.info("Custom component information successfully shared!")
 
@@ -577,7 +597,7 @@ def _get_file_from_prompt_in_loop() -> tuple[bytes, str] | None:
             image_file = image_file_path.read_bytes()
         except OSError as ose:
             console.error(f"Unable to read the {file_extension} file due to {ose}")
-            raise typer.Exit(code=1) from ose
+            raise click.exceptions.Exit(code=1) from ose
         else:
             return image_file, file_extension
 
@@ -586,38 +606,21 @@ def _get_file_from_prompt_in_loop() -> tuple[bytes, str] | None:
 
 
 @custom_components_cli.command(name="share")
-def share_more_detail(
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-):
-    """Collect more details on the published package for gallery.
-
-    Args:
-        loglevel: The log level to use.
-    """
-    console.set_log_level(loglevel or get_config().loglevel)
-
+@loglevel_option
+def share_more_detail():
+    """Collect more details on the published package for gallery."""
     _collect_details_for_gallery()
 
 
-@custom_components_cli.command()
-def install(
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-):
+@custom_components_cli.command(name="install")
+@loglevel_option
+def install():
     """Install package from this local custom component in editable mode.
 
-    Args:
-        loglevel: The log level to use.
-
     Raises:
         Exit: If unable to install the current directory in editable mode.
     """
-    console.set_log_level(loglevel or get_config().loglevel)
-
     if _pip_install_on_demand(package_name=".", install_args=["-e"]):
         console.info("Package installed successfully!")
     else:
-        raise typer.Exit(code=1)
+        raise click.exceptions.Exit(code=1)

+ 8 - 0
reflex/page.py

@@ -8,6 +8,7 @@ from typing import Any
 
 from reflex.config import get_config
 from reflex.event import EventType
+from reflex.utils import console
 
 DECORATED_PAGES: dict[str, list] = defaultdict(list)
 
@@ -76,6 +77,13 @@ def get_decorated_pages(omit_implicit_routes: bool = True) -> list[dict[str, Any
     Returns:
         The decorated pages.
     """
+    console.deprecate(
+        "get_decorated_pages",
+        reason="This function is deprecated and will be removed in a future version.",
+        deprecation_version="0.7.9",
+        removal_version="0.8.0",
+        dedupe=True,
+    )
     return sorted(
         [
             page_data

+ 279 - 267
reflex/reflex.py

@@ -3,74 +3,63 @@
 from __future__ import annotations
 
 import atexit
+from importlib.util import find_spec
 from pathlib import Path
 from typing import TYPE_CHECKING
 
-import typer
-import typer.core
+import click
 from reflex_cli.v2.deployments import hosting_cli
 
 from reflex import constants
 from reflex.config import environment, get_config
+from reflex.constants.base import LITERAL_ENV
 from reflex.custom_components.custom_components import custom_components_cli
 from reflex.state import reset_disk_state_manager
 from reflex.utils import console, redir, telemetry
 from reflex.utils.exec import should_use_granian
 
-# Disable typer+rich integration for help panels
-typer.core.rich = None  # pyright: ignore [reportPrivateImportUsage]
 
-# Create the app.
-cli = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
-
-
-def version(value: bool):
-    """Get the Reflex version.
+def set_loglevel(ctx: click.Context, self: click.Parameter, value: str | None):
+    """Set the log level.
 
     Args:
-        value: Whether the version flag was passed.
-
-    Raises:
-        typer.Exit: If the version flag was passed.
+        ctx: The click context.
+        self: The click command.
+        value: The log level to set.
     """
-    if value:
-        console.print(constants.Reflex.VERSION)
-        raise typer.Exit()
-
-
-@cli.callback()
-def main(
-    version: bool = typer.Option(
-        None,
-        "-v",
-        "--version",
-        callback=version,
-        help="Get the Reflex version.",
-        is_eager=True,
-    ),
-):
+    if value is not None:
+        loglevel = constants.LogLevel.from_string(value)
+        console.set_log_level(loglevel)
+
+
+@click.group
+@click.version_option(constants.Reflex.VERSION, message="%(version)s")
+def cli():
     """Reflex CLI to create, run, and deploy apps."""
     pass
 
 
+loglevel_option = click.option(
+    "--loglevel",
+    type=click.Choice(
+        [loglevel.value for loglevel in constants.LogLevel],
+        case_sensitive=False,
+    ),
+    is_eager=True,
+    callback=set_loglevel,
+    expose_value=False,
+    help="The log level to use.",
+)
+
+
 def _init(
     name: str,
     template: str | None = None,
-    loglevel: constants.LogLevel | None = None,
     ai: bool = False,
 ):
     """Initialize a new Reflex app in the given directory."""
     from reflex.utils import exec, prerequisites
 
-    if loglevel is not None:
-        console.set_log_level(loglevel)
-
-    config = get_config()
-
-    # Set the log level.
-    loglevel = loglevel or config.loglevel
-    console.set_log_level(loglevel)
-
     # Show system info
     exec.output_system_info()
 
@@ -97,7 +86,7 @@ def _init(
     prerequisites.initialize_gitignore()
 
     # Initialize the requirements.txt.
-    wrote_to_requirements = prerequisites.initialize_requirements_txt()
+    needs_user_manual_update = prerequisites.initialize_requirements_txt()
 
     template_msg = f" using the {template} template" if template else ""
     # Finish initializing the app.
@@ -105,31 +94,35 @@ def _init(
         f"Initialized {app_name}{template_msg}."
         + (
             f" Make sure to add {constants.RequirementsTxt.DEFAULTS_STUB + constants.Reflex.VERSION} to your requirements.txt or pyproject.toml file."
-            if not wrote_to_requirements
+            if needs_user_manual_update
             else ""
         )
     )
 
 
 @cli.command()
+@loglevel_option
+@click.option(
+    "--name",
+    metavar="APP_NAME",
+    help="The name of the app to initialize.",
+)
+@click.option(
+    "--template",
+    help="The template to initialize the app with.",
+)
+@click.option(
+    "--ai",
+    is_flag=True,
+    help="Use AI to create the initial template. Cannot be used with existing app or `--template` option.",
+)
 def init(
-    name: str = typer.Option(
-        None, metavar="APP_NAME", help="The name of the app to initialize."
-    ),
-    template: str = typer.Option(
-        None,
-        help="The template to initialize the app with.",
-    ),
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-    ai: bool = typer.Option(
-        False,
-        help="Use AI to create the initial template. Cannot be used with existing app or `--template` option.",
-    ),
+    name: str,
+    template: str | None,
+    ai: bool,
 ):
     """Initialize a new Reflex app in the current directory."""
-    _init(name, template, loglevel, ai)
+    _init(name, template, ai)
 
 
 def _run(
@@ -139,22 +132,14 @@ def _run(
     frontend_port: int | None = None,
     backend_port: int | None = None,
     backend_host: str | None = None,
-    loglevel: constants.LogLevel | None = None,
 ):
     """Run the app in the given directory."""
     from reflex.utils import build, exec, prerequisites, processes
 
-    if loglevel is not None:
-        console.set_log_level(loglevel)
-
     config = get_config()
 
-    loglevel = loglevel or config.loglevel
     backend_host = backend_host or config.backend_host
 
-    # Set the log level.
-    console.set_log_level(loglevel)
-
     # Set env mode in the environment
     environment.REFLEX_ENV_MODE.set(env)
 
@@ -168,7 +153,7 @@ def _run(
 
     # Check that the app is initialized.
     if prerequisites.needs_reinit(frontend=frontend):
-        _init(name=config.app_name, loglevel=loglevel)
+        _init(name=config.app_name)
 
     # Delete the states folder if it exists.
     reset_disk_state_manager()
@@ -228,7 +213,7 @@ def _run(
     else:
         validation_result = app_task(*args)
     if not validation_result:
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     # Warn if schema is not up to date.
     prerequisites.check_schema_up_to_date()
@@ -248,7 +233,7 @@ def _run(
             exec.run_backend_prod,
         )
     if not setup_frontend or not frontend_cmd or not backend_cmd:
-        raise ValueError("Invalid env")
+        raise ValueError(f"Invalid env: {env}. Must be DEV or PROD.")
 
     # Post a telemetry event.
     telemetry.send(f"run-{env.value}")
@@ -271,7 +256,7 @@ def _run(
                 backend_cmd,
                 backend_host,
                 backend_port,
-                loglevel.subprocess_level(),
+                config.loglevel.subprocess_level(),
                 frontend,
             )
         )
@@ -281,7 +266,10 @@ def _run(
         # In dev mode, run the backend on the main thread.
         if backend and backend_port and env == constants.Env.DEV:
             backend_cmd(
-                backend_host, int(backend_port), loglevel.subprocess_level(), frontend
+                backend_host,
+                int(backend_port),
+                config.loglevel.subprocess_level(),
+                frontend,
             )
             # The windows uvicorn bug workaround
             # https://github.com/reflex-dev/reflex/issues/2335
@@ -291,94 +279,123 @@ def _run(
 
 
 @cli.command()
+@loglevel_option
+@click.option(
+    "--env",
+    type=click.Choice([e.value for e in constants.Env], case_sensitive=False),
+    default=constants.Env.DEV.value,
+    help="The environment to run the app in.",
+)
+@click.option(
+    "--frontend-only",
+    is_flag=True,
+    show_default=False,
+    help="Execute only frontend.",
+    envvar=environment.REFLEX_FRONTEND_ONLY.name,
+)
+@click.option(
+    "--backend-only",
+    is_flag=True,
+    show_default=False,
+    help="Execute only backend.",
+    envvar=environment.REFLEX_BACKEND_ONLY.name,
+)
+@click.option(
+    "--frontend-port",
+    type=int,
+    help="Specify a different frontend port.",
+    envvar=environment.REFLEX_FRONTEND_PORT.name,
+)
+@click.option(
+    "--backend-port",
+    type=int,
+    help="Specify a different backend port.",
+    envvar=environment.REFLEX_BACKEND_PORT.name,
+)
+@click.option(
+    "--backend-host",
+    help="Specify the backend host.",
+)
 def run(
-    env: constants.Env = typer.Option(
-        constants.Env.DEV, help="The environment to run the app in."
-    ),
-    frontend: bool = typer.Option(
-        False,
-        "--frontend-only",
-        help="Execute only frontend.",
-        envvar=environment.REFLEX_FRONTEND_ONLY.name,
-    ),
-    backend: bool = typer.Option(
-        False,
-        "--backend-only",
-        help="Execute only backend.",
-        envvar=environment.REFLEX_BACKEND_ONLY.name,
-    ),
-    frontend_port: int | None = typer.Option(
-        None,
-        help="Specify a different frontend port.",
-        envvar=environment.REFLEX_FRONTEND_PORT.name,
-    ),
-    backend_port: int | None = typer.Option(
-        None,
-        help="Specify a different backend port.",
-        envvar=environment.REFLEX_BACKEND_PORT.name,
-    ),
-    backend_host: str | None = typer.Option(None, help="Specify the backend host."),
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
+    env: LITERAL_ENV,
+    frontend_only: bool,
+    backend_only: bool,
+    frontend_port: int | None,
+    backend_port: int | None,
+    backend_host: str | None,
 ):
     """Run the app in the current directory."""
-    if frontend and backend:
+    if frontend_only and backend_only:
         console.error("Cannot use both --frontend-only and --backend-only options.")
-        raise typer.Exit(1)
-
-    if loglevel is not None:
-        console.set_log_level(loglevel)
+        raise click.exceptions.Exit(1)
 
     config = get_config()
 
     frontend_port = frontend_port or config.frontend_port
     backend_port = backend_port or config.backend_port
     backend_host = backend_host or config.backend_host
-    loglevel = loglevel or config.loglevel
 
     environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.RUN)
-    environment.REFLEX_BACKEND_ONLY.set(backend)
-    environment.REFLEX_FRONTEND_ONLY.set(frontend)
-
-    _run(env, frontend, backend, frontend_port, backend_port, backend_host, loglevel)
+    environment.REFLEX_BACKEND_ONLY.set(backend_only)
+    environment.REFLEX_FRONTEND_ONLY.set(frontend_only)
+
+    _run(
+        constants.Env.DEV if env == constants.Env.DEV else constants.Env.PROD,
+        frontend_only,
+        backend_only,
+        frontend_port,
+        backend_port,
+        backend_host,
+    )
 
 
 @cli.command()
+@loglevel_option
+@click.option(
+    "--zip/--no-zip",
+    default=True,
+    is_flag=True,
+    help="Whether to zip the backend and frontend exports.",
+)
+@click.option(
+    "--frontend-only",
+    is_flag=True,
+    show_default=False,
+    envvar=environment.REFLEX_FRONTEND_ONLY.name,
+    help="Export only frontend.",
+)
+@click.option(
+    "--backend-only",
+    is_flag=True,
+    show_default=False,
+    envvar=environment.REFLEX_BACKEND_ONLY.name,
+    help="Export only backend.",
+)
+@click.option(
+    "--zip-dest-dir",
+    default=str(Path.cwd()),
+    help="The directory to export the zip files to.",
+    show_default=False,
+)
+@click.option(
+    "--upload-db-file",
+    is_flag=True,
+    help="Whether to exclude sqlite db files when exporting backend.",
+    hidden=True,
+)
+@click.option(
+    "--env",
+    type=click.Choice([e.value for e in constants.Env], case_sensitive=False),
+    default=constants.Env.PROD.value,
+    help="The environment to export the app in.",
+)
 def export(
-    zipping: bool = typer.Option(
-        True, "--no-zip", help="Disable zip for backend and frontend exports."
-    ),
-    frontend: bool = typer.Option(
-        False,
-        "--frontend-only",
-        help="Export only frontend.",
-        show_default=False,
-        envvar=environment.REFLEX_FRONTEND_ONLY.name,
-    ),
-    backend: bool = typer.Option(
-        False,
-        "--backend-only",
-        help="Export only backend.",
-        show_default=False,
-        envvar=environment.REFLEX_BACKEND_ONLY.name,
-    ),
-    zip_dest_dir: str = typer.Option(
-        str(Path.cwd()),
-        help="The directory to export the zip files to.",
-        show_default=False,
-    ),
-    upload_db_file: bool = typer.Option(
-        False,
-        help="Whether to exclude sqlite db files when exporting backend.",
-        hidden=True,
-    ),
-    env: constants.Env = typer.Option(
-        constants.Env.PROD, help="The environment to export the app in."
-    ),
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
+    zip: bool,
+    frontend_only: bool,
+    backend_only: bool,
+    zip_dest_dir: str,
+    upload_db_file: bool,
+    env: LITERAL_ENV,
 ):
     """Export the app to a zip file."""
     from reflex.utils import export as export_utils
@@ -386,35 +403,33 @@ def export(
 
     environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.EXPORT)
 
-    frontend, backend = prerequisites.check_running_mode(frontend, backend)
+    frontend_only, backend_only = prerequisites.check_running_mode(
+        frontend_only, backend_only
+    )
 
-    loglevel = loglevel or get_config().loglevel
-    console.set_log_level(loglevel)
+    config = get_config()
 
-    if prerequisites.needs_reinit(frontend=frontend or not backend):
-        _init(name=get_config().app_name, loglevel=loglevel)
+    if prerequisites.needs_reinit(frontend=frontend_only or not backend_only):
+        _init(name=config.app_name)
 
     export_utils.export(
-        zipping=zipping,
-        frontend=frontend,
-        backend=backend,
+        zipping=zip,
+        frontend=frontend_only,
+        backend=backend_only,
         zip_dest_dir=zip_dest_dir,
         upload_db_file=upload_db_file,
-        env=env,
-        loglevel=loglevel.subprocess_level(),
+        env=constants.Env.DEV if env == constants.Env.DEV else constants.Env.PROD,
+        loglevel=config.loglevel.subprocess_level(),
     )
 
 
 @cli.command()
-def login(loglevel: constants.LogLevel | None = typer.Option(None)):
+@loglevel_option
+def login():
     """Authenticate with experimental Reflex hosting service."""
     from reflex_cli.v2 import cli as hosting_cli
     from reflex_cli.v2.deployments import check_version
 
-    loglevel = loglevel or get_config().loglevel
-
-    console.set_log_level(loglevel)
-
     check_version()
 
     validated_info = hosting_cli.login()
@@ -424,24 +439,27 @@ def login(loglevel: constants.LogLevel | None = typer.Option(None)):
 
 
 @cli.command()
-def logout(
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-):
+@loglevel_option
+def logout():
     """Log out of access to Reflex hosting service."""
     from reflex_cli.v2.cli import logout
     from reflex_cli.v2.deployments import check_version
 
     check_version()
 
-    loglevel = loglevel or get_config().loglevel
+    logout(_convert_reflex_loglevel_to_reflex_cli_loglevel(get_config().loglevel))
 
-    logout(_convert_reflex_loglevel_to_reflex_cli_loglevel(loglevel))
 
+@click.group
+def db_cli():
+    """Subcommands for managing the database schema."""
+    pass
 
-db_cli = typer.Typer()
-script_cli = typer.Typer()
+
+@click.group
+def script_cli():
+    """Subcommands for running helper scripts."""
+    pass
 
 
 def _skip_compile():
@@ -495,11 +513,11 @@ def migrate():
 
 
 @db_cli.command()
-def makemigrations(
-    message: str = typer.Option(
-        None, help="Human readable identifier for the generated revision."
-    ),
-):
+@click.option(
+    "--message",
+    help="Human readable identifier for the generated revision.",
+)
+def makemigrations(message: str | None):
     """Create autogenerated alembic migration scripts."""
     from alembic.util.exc import CommandError
 
@@ -523,70 +541,74 @@ def makemigrations(
 
 
 @cli.command()
+@loglevel_option
+@click.option(
+    "--app-name",
+    help="The name of the app to deploy.",
+)
+@click.option(
+    "--app-id",
+    help="The ID of the app to deploy.",
+)
+@click.option(
+    "-r",
+    "--region",
+    multiple=True,
+    help="The regions to deploy to. `reflex cloud regions` For multiple envs, repeat this option, e.g. --region sjc --region iad",
+)
+@click.option(
+    "--env",
+    multiple=True,
+    help="The environment variables to set: <key>=<value>. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.",
+)
+@click.option(
+    "--vmtype",
+    help="Vm type id. Run `reflex cloud vmtypes` to get options.",
+)
+@click.option(
+    "--hostname",
+    help="The hostname of the frontend.",
+)
+@click.option(
+    "--interactive/--no-interactive",
+    is_flag=True,
+    default=True,
+    help="Whether to list configuration options and ask for confirmation.",
+)
+@click.option(
+    "--envfile",
+    help="The path to an env file to use. Will override any envs set manually.",
+)
+@click.option(
+    "--project",
+    help="project id to deploy to",
+)
+@click.option(
+    "--project-name",
+    help="The name of the project to deploy to.",
+)
+@click.option(
+    "--token",
+    help="token to use for auth",
+)
+@click.option(
+    "--config-path",
+    "--config",
+    help="path to the config file",
+)
 def deploy(
-    app_name: str | None = typer.Option(
-        None,
-        "--app-name",
-        help="The name of the App to deploy under.",
-    ),
-    app_id: str = typer.Option(
-        None,
-        "--app-id",
-        help="The ID of the App to deploy over.",
-    ),
-    regions: list[str] = typer.Option(
-        [],
-        "-r",
-        "--region",
-        help="The regions to deploy to. `reflex cloud regions` For multiple envs, repeat this option, e.g. --region sjc --region iad",
-    ),
-    envs: list[str] = typer.Option(
-        [],
-        "--env",
-        help="The environment variables to set: <key>=<value>. For multiple envs, repeat this option, e.g. --env k1=v2 --env k2=v2.",
-    ),
-    vmtype: str | None = typer.Option(
-        None,
-        "--vmtype",
-        help="Vm type id. Run `reflex cloud vmtypes` to get options.",
-    ),
-    hostname: str | None = typer.Option(
-        None,
-        "--hostname",
-        help="The hostname of the frontend.",
-    ),
-    interactive: bool = typer.Option(
-        True,
-        help="Whether to list configuration options and ask for confirmation.",
-    ),
-    envfile: str | None = typer.Option(
-        None,
-        "--envfile",
-        help="The path to an env file to use. Will override any envs set manually.",
-    ),
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-    project: str | None = typer.Option(
-        None,
-        "--project",
-        help="project id to deploy to",
-    ),
-    project_name: str | None = typer.Option(
-        None,
-        "--project-name",
-        help="The name of the project to deploy to.",
-    ),
-    token: str | None = typer.Option(
-        None,
-        "--token",
-        help="token to use for auth",
-    ),
-    config_path: str | None = typer.Option(
-        None,
-        "--config",
-        help="path to the config file",
-    ),
+    app_name: str | None,
+    app_id: str | None,
+    region: tuple[str, ...],
+    env: tuple[str],
+    vmtype: str | None,
+    hostname: str | None,
+    interactive: bool,
+    envfile: str | None,
+    project: str | None,
+    project_name: str | None,
+    token: str | None,
+    config_path: str | None,
 ):
     """Deploy the app to the Reflex hosting service."""
     from reflex_cli.utils import dependency
@@ -596,21 +618,14 @@ def deploy(
     from reflex.utils import export as export_utils
     from reflex.utils import prerequisites
 
-    if loglevel is not None:
-        console.set_log_level(loglevel)
-
     config = get_config()
 
-    loglevel = loglevel or config.loglevel
     app_name = app_name or config.app_name
 
     check_version()
 
     environment.REFLEX_COMPILE_CONTEXT.set(constants.CompileContext.DEPLOY)
 
-    # Set the log level.
-    console.set_log_level(loglevel)
-
     # Only check requirements if interactive.
     # There is user interaction for requirements update.
     if interactive:
@@ -618,7 +633,7 @@ def deploy(
 
     # Check if we are set up.
     if prerequisites.needs_reinit(frontend=True):
-        _init(name=config.app_name, loglevel=loglevel)
+        _init(name=config.app_name)
     prerequisites.check_latest_package_version(constants.ReflexHostingCLI.MODULE_NAME)
 
     hosting_cli.deploy(
@@ -638,17 +653,17 @@ def deploy(
                 frontend=frontend,
                 backend=backend,
                 zipping=zipping,
-                loglevel=loglevel.subprocess_level(),
+                loglevel=config.loglevel.subprocess_level(),
                 upload_db_file=upload_db,
             )
         ),
-        regions=regions,
-        envs=envs,
+        regions=list(region),
+        envs=list(env),
         vmtype=vmtype,
         envfile=envfile,
         hostname=hostname,
         interactive=interactive,
-        loglevel=_convert_reflex_loglevel_to_reflex_cli_loglevel(loglevel),
+        loglevel=_convert_reflex_loglevel_to_reflex_cli_loglevel(config.loglevel),
         token=token,
         project=project,
         project_name=project_name,
@@ -657,19 +672,14 @@ def deploy(
 
 
 @cli.command()
-def rename(
-    new_name: str = typer.Argument(..., help="The new name for the app."),
-    loglevel: constants.LogLevel | None = typer.Option(
-        None, help="The log level to use."
-    ),
-):
+@loglevel_option
+@click.argument("new_name")
+def rename(new_name: str):
     """Rename the app in the current directory."""
     from reflex.utils import prerequisites
 
-    loglevel = loglevel or get_config().loglevel
-
     prerequisites.validate_app_name(new_name)
-    prerequisites.rename_app(new_name, loglevel)
+    prerequisites.rename_app(new_name, get_config().loglevel)
 
 
 if TYPE_CHECKING:
@@ -702,18 +712,20 @@ def _convert_reflex_loglevel_to_reflex_cli_loglevel(
     return HostingLogLevel.INFO
 
 
-cli.add_typer(db_cli, name="db", help="Subcommands for managing the database schema.")
-cli.add_typer(script_cli, name="script", help="Subcommands running helper scripts.")
-cli.add_typer(
-    hosting_cli,
-    name="cloud",
-    help="Subcommands for managing the reflex cloud.",
-)
-cli.add_typer(
-    custom_components_cli,
-    name="component",
-    help="Subcommands for creating and publishing Custom Components.",
-)
+if find_spec("typer") and find_spec("typer.main"):
+    import typer  # pyright: ignore[reportMissingImports]
+
+    if isinstance(hosting_cli, typer.Typer):
+        hosting_cli_command = typer.main.get_command(hosting_cli)
+    else:
+        hosting_cli_command = hosting_cli
+else:
+    hosting_cli_command = hosting_cli
+
+cli.add_command(hosting_cli_command, name="cloud")
+cli.add_command(db_cli, name="db")
+cli.add_command(script_cli, name="script")
+cli.add_command(custom_components_cli, name="component")
 
 if __name__ == "__main__":
     cli()

+ 1 - 0
reflex/state.py

@@ -3920,6 +3920,7 @@ class MutableProxy(wrapt.ObjectProxy):
         if self._is_mutable_type(value) and __name not in (
             "__wrapped__",
             "_self_state",
+            "__dict__",
         ):
             # Recursively wrap mutable attribute values retrieved through this proxy.
             return self._wrap_recursive(value)

+ 12 - 5
reflex/testing.py

@@ -34,7 +34,7 @@ import reflex.utils.format
 import reflex.utils.prerequisites
 import reflex.utils.processes
 from reflex.components.component import CustomComponent
-from reflex.config import environment
+from reflex.config import environment, get_config
 from reflex.state import (
     BaseState,
     StateManager,
@@ -44,6 +44,7 @@ from reflex.state import (
     reload_state_module,
 )
 from reflex.utils import console
+from reflex.utils.export import export
 
 try:
     from selenium import webdriver
@@ -252,11 +253,11 @@ class AppHarness:
                     self._get_source_from_app_source(self.app_source),
                 ]
             )
+            get_config().loglevel = reflex.constants.LogLevel.INFO
             with chdir(self.app_path):
                 reflex.reflex._init(
                     name=self.app_name,
                     template=reflex.constants.Templates.DEFAULT,
-                    loglevel=reflex.constants.LogLevel.INFO,
                 )
                 self.app_module_path.write_text(source_code)
         else:
@@ -322,11 +323,11 @@ class AppHarness:
         return _shutdown
 
     def _start_backend(self, port: int = 0):
-        if self.app_instance is None or self.app_instance.api is None:
+        if self.app_instance is None or self.app_instance._api is None:
             raise RuntimeError("App was not initialized.")
         self.backend = uvicorn.Server(
             uvicorn.Config(
-                app=self.app_instance.api,
+                app=self.app_instance._api,
                 host="127.0.0.1",
                 port=port,
             )
@@ -933,7 +934,13 @@ class AppHarnessProd(AppHarness):
             config.api_url = "http://{}:{}".format(
                 *self._poll_for_servers().getsockname(),
             )
-            reflex.reflex.export(
+
+            get_config().loglevel = reflex.constants.LogLevel.INFO
+
+            if reflex.utils.prerequisites.needs_reinit(frontend=True):
+                reflex.reflex._init(name=get_config().app_name)
+
+            export(
                 zipping=False,
                 frontend=True,
                 backend=False,

+ 6 - 2
reflex/utils/codespaces.py

@@ -4,7 +4,8 @@ from __future__ import annotations
 
 import os
 
-from fastapi.responses import HTMLResponse
+from starlette.requests import Request
+from starlette.responses import HTMLResponse
 
 from reflex.components.base.script import Script
 from reflex.components.component import Component
@@ -74,9 +75,12 @@ def codespaces_auto_redirect() -> list[Component]:
     return []
 
 
-async def auth_codespace() -> HTMLResponse:
+async def auth_codespace(_request: Request) -> HTMLResponse:
     """Page automatically redirecting back to the app after authenticating a codespace port forward.
 
+    Args:
+        _request: The request object.
+
     Returns:
         An HTML response with an embedded script to redirect back to the app.
     """

+ 4 - 3
reflex/utils/console.py

@@ -47,7 +47,7 @@ _EMITTED_LOGS = set()
 _EMITTED_PRINTS = set()
 
 
-def set_log_level(log_level: LogLevel):
+def set_log_level(log_level: LogLevel | None):
     """Set the log level.
 
     Args:
@@ -56,6 +56,8 @@ def set_log_level(log_level: LogLevel):
     Raises:
         TypeError: If the log level is a string.
     """
+    if log_level is None:
+        return
     if not isinstance(log_level, LogLevel):
         raise TypeError(
             f"log_level must be a LogLevel enum value, got {log_level} of type {type(log_level)} instead."
@@ -193,13 +195,12 @@ def warn(msg: str, dedupe: bool = False, **kwargs):
 
 def _get_first_non_framework_frame() -> FrameType | None:
     import click
-    import typer
     import typing_extensions
 
     import reflex as rx
 
     # Exclude utility modules that should never be the source of deprecated reflex usage.
-    exclude_modules = [click, rx, typer, typing_extensions]
+    exclude_modules = [click, rx, typing_extensions]
     exclude_roots = [
         p.parent.resolve() if (p := Path(file)).name == "__init__.py" else p.resolve()
         for m in exclude_modules

+ 59 - 21
reflex/utils/exec.py

@@ -189,11 +189,9 @@ def run_frontend_prod(root: Path, port: str, backend_present: bool = True):
 
 @once
 def _warn_user_about_uvicorn():
-    # When we eventually switch to Granian by default, we should enable this warning.
-    if False:
-        console.warn(
-            "Using Uvicorn for backend as it is installed. This behavior will change in 0.8.0 to use Granian by default."
-        )
+    console.warn(
+        "Using Uvicorn for backend as it is installed. This behavior will change in 0.8.0 to use Granian by default."
+    )
 
 
 def should_use_granian():
@@ -202,8 +200,8 @@ def should_use_granian():
     Returns:
         True if Granian should be used.
     """
-    if environment.REFLEX_USE_GRANIAN.get():
-        return True
+    if environment.REFLEX_USE_GRANIAN.is_set():
+        return environment.REFLEX_USE_GRANIAN.get()
     if (
         importlib.util.find_spec("uvicorn") is None
         or importlib.util.find_spec("gunicorn") is None
@@ -219,9 +217,51 @@ def get_app_module():
     Returns:
         The app module for the backend.
     """
-    config = get_config()
+    return get_config().module
+
+
+def get_app_instance():
+    """Get the app module for the backend.
+
+    Returns:
+        The app module for the backend.
+    """
+    return f"{get_app_module()}:{constants.CompileVars.APP}"
 
-    return f"{config.module}:{constants.CompileVars.APP}"
+
+def get_app_file() -> Path:
+    """Get the app file for the backend.
+
+    Returns:
+        The app file for the backend.
+
+    Raises:
+        ImportError: If the app module is not found.
+    """
+    current_working_dir = str(Path.cwd())
+    if current_working_dir not in sys.path:
+        # Add the current working directory to sys.path
+        sys.path.insert(0, current_working_dir)
+    module_spec = importlib.util.find_spec(get_app_module())
+    if module_spec is None:
+        raise ImportError(
+            f"Module {get_app_module()} not found. Make sure the module is installed."
+        )
+    file_name = module_spec.origin
+    if file_name is None:
+        raise ImportError(
+            f"Module {get_app_module()} not found. Make sure the module is installed."
+        )
+    return Path(file_name).resolve()
+
+
+def get_app_instance_from_file() -> str:
+    """Get the app module for the backend.
+
+    Returns:
+        The app module for the backend.
+    """
+    return f"{get_app_file()}:{constants.CompileVars.APP}"
 
 
 def run_backend(
@@ -323,7 +363,7 @@ def run_uvicorn_backend(host: str, port: int, loglevel: LogLevel):
     import uvicorn
 
     uvicorn.run(
-        app=f"{get_app_module()}",
+        app=f"{get_app_instance()}",
         factory=True,
         host=host,
         port=port,
@@ -349,7 +389,7 @@ def run_granian_backend(host: str, port: int, loglevel: LogLevel):
     from granian.server import MPServer as Granian
 
     Granian(
-        target=get_app_module(),
+        target=get_app_instance_from_file(),
         factory=True,
         address=host,
         port=port,
@@ -367,14 +407,12 @@ def _deprecate_asgi_config(
     config_name: str,
     reason: str = "",
 ):
-    # When we eventually switch to Granian by default, we should enable this deprecation.
-    if False:
-        console.deprecate(
-            f"config.{config_name}",
-            reason=reason,
-            deprecation_version="0.7.5",
-            removal_version="0.8.0",
-        )
+    console.deprecate(
+        f"config.{config_name}",
+        reason=reason,
+        deprecation_version="0.7.9",
+        removal_version="0.8.0",
+    )
 
 
 @once
@@ -468,7 +506,7 @@ def run_uvicorn_backend_prod(host: str, port: int, loglevel: LogLevel):
 
     config = get_config()
 
-    app_module = get_app_module()
+    app_module = get_app_instance()
 
     command = (
         [
@@ -568,7 +606,7 @@ def run_granian_backend_prod(host: str, port: int, loglevel: LogLevel):
             *("--host", host),
             *("--port", str(port)),
             *("--interface", str(Interfaces.ASGI)),
-            *("--factory", get_app_module()),
+            *("--factory", get_app_instance_from_file()),
         ]
         processes.new_process(
             command,

+ 38 - 32
reflex/utils/prerequisites.py

@@ -26,8 +26,8 @@ from types import ModuleType
 from typing import NamedTuple
 from urllib.parse import urlparse
 
+import click
 import httpx
-import typer
 from alembic.util.exc import CommandError
 from packaging import version
 from redis import Redis as RedisSync
@@ -513,7 +513,7 @@ def compile_or_validate_app(compile: bool = False) -> bool:
         else:
             validate_app()
     except Exception as e:
-        if isinstance(e, typer.Exit):
+        if isinstance(e, click.exceptions.Exit):
             return False
 
         import traceback
@@ -617,14 +617,14 @@ def validate_app_name(app_name: str | None = None) -> str:
         console.error(
             f"The app directory cannot be named [bold]{constants.Reflex.MODULE_NAME}[/bold]."
         )
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     # Make sure the app name is standard for a python package name.
     if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", app_name):
         console.error(
             "The app directory name must start with a letter and can contain letters, numbers, and underscores."
         )
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     return app_name
 
@@ -683,7 +683,7 @@ def rename_app(new_app_name: str, loglevel: constants.LogLevel):
         console.error(
             "No rxconfig.py found. Make sure you are in the root directory of your app."
         )
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     sys.path.insert(0, str(Path.cwd()))
 
@@ -691,11 +691,11 @@ def rename_app(new_app_name: str, loglevel: constants.LogLevel):
     module_path = importlib.util.find_spec(config.module)
     if module_path is None:
         console.error(f"Could not find module {config.module}.")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     if not module_path.origin:
         console.error(f"Could not find origin for module {config.module}.")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
     console.info(f"Renaming app directory to {new_app_name}.")
     process_directory(
         Path.cwd(),
@@ -836,18 +836,24 @@ def initialize_gitignore(
 
 def initialize_requirements_txt() -> bool:
     """Initialize the requirements.txt file.
-    If absent, generate one for the user.
+    If absent and no pyproject.toml file exists, generate one for the user.
     If the requirements.txt does not have reflex as dependency,
     generate a requirement pinning current version and append to
     the requirements.txt file.
 
     Returns:
-        True if the requirements.txt file was created or updated, False otherwise.
+        True if the user has to update the requirements.txt file.
 
     Raises:
         Exit: If the requirements.txt file cannot be read or written to.
     """
     requirements_file_path = Path(constants.RequirementsTxt.FILE)
+    if (
+        not requirements_file_path.exists()
+        and Path(constants.PyprojectToml.FILE).exists()
+    ):
+        return True
+
     requirements_file_path.touch(exist_ok=True)
 
     for encoding in [None, "utf-8"]:
@@ -858,14 +864,14 @@ def initialize_requirements_txt() -> bool:
             continue
         except Exception as e:
             console.error(f"Failed to read {requirements_file_path}.")
-            raise typer.Exit(1) from e
+            raise click.exceptions.Exit(1) from e
     else:
-        return False
+        return True
 
     for line in content.splitlines():
         if re.match(r"^reflex[^a-zA-Z0-9]", line):
             console.debug(f"{requirements_file_path} already has reflex as dependency.")
-            return True
+            return False
 
     console.debug(
         f"Appending {constants.RequirementsTxt.DEFAULTS_STUB} to {requirements_file_path}"
@@ -875,7 +881,7 @@ def initialize_requirements_txt() -> bool:
             "\n" + constants.RequirementsTxt.DEFAULTS_STUB + constants.Reflex.VERSION
         )
 
-    return True
+    return False
 
 
 def initialize_app_directory(
@@ -903,7 +909,7 @@ def initialize_app_directory(
             console.error(
                 f"Only {template_name=} should be provided, got {template_code_dir_name=}, {template_dir=}."
             )
-            raise typer.Exit(1)
+            raise click.exceptions.Exit(1)
         template_code_dir_name = constants.Templates.Dirs.CODE
         template_dir = Path(constants.Templates.Dirs.BASE, "apps", template_name)
     else:
@@ -911,7 +917,7 @@ def initialize_app_directory(
             console.error(
                 f"For `{template_name}` template, `template_code_dir_name` and `template_dir` should both be provided."
             )
-            raise typer.Exit(1)
+            raise click.exceptions.Exit(1)
 
     console.debug(f"Using {template_name=} {template_dir=} {template_code_dir_name=}.")
 
@@ -1143,7 +1149,7 @@ def download_and_run(url: str, *args, show_status: bool = False, **env):
         console.error(
             f"Failed to download bun install script. You can install or update bun manually from https://bun.sh \n{e}"
         )
-        raise typer.Exit(1) from None
+        raise click.exceptions.Exit(1) from None
 
     # Save the script to a temporary file.
     script = Path(tempfile.NamedTemporaryFile().name)
@@ -1372,7 +1378,7 @@ def needs_reinit(frontend: bool = True) -> bool:
         console.error(
             f"[cyan]{constants.Config.FILE}[/cyan] not found. Move to the root folder of your project, or run [bold]{constants.Reflex.MODULE_NAME} init[/bold] to start a new project."
         )
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     # Don't need to reinit if not running in frontend mode.
     if not frontend:
@@ -1437,7 +1443,7 @@ def validate_bun(bun_path: Path | None = None):
             console.error(
                 "Failed to obtain bun version. Make sure the specified bun path in your config is correct."
             )
-            raise typer.Exit(1)
+            raise click.exceptions.Exit(1)
         elif bun_version < version.parse(constants.Bun.MIN_VERSION):
             console.warn(
                 f"Reflex requires bun version {constants.Bun.MIN_VERSION} or higher to run, but the detected version is "
@@ -1459,14 +1465,14 @@ def validate_frontend_dependencies(init: bool = True):
         try:
             get_js_package_executor(raise_on_none=True)
         except FileNotFoundError as e:
-            raise typer.Exit(1) from e
+            raise click.exceptions.Exit(1) from e
 
     if prefer_npm_over_bun() and not check_node_version():
         node_version = get_node_version()
         console.error(
             f"Reflex requires node version {constants.Node.MIN_VERSION} or higher to run, but the detected version is {node_version}",
         )
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
 
 def ensure_reflex_installation_id() -> int | None:
@@ -1613,17 +1619,17 @@ def prompt_for_template_options(templates: list[Template]) -> str:
 
     if not template:
         console.error("No template selected.")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     try:
         template_index = int(template)
     except ValueError:
         console.error("Invalid template selected.")
-        raise typer.Exit(1) from None
+        raise click.exceptions.Exit(1) from None
 
     if template_index < 0 or template_index >= len(templates):
         console.error("Invalid template selected.")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     # Return the template.
     return templates[template_index].name
@@ -1703,7 +1709,7 @@ def create_config_init_app_from_remote_template(app_name: str, template_url: str
         temp_dir = tempfile.mkdtemp()
     except OSError as ose:
         console.error(f"Failed to create temp directory for download: {ose}")
-        raise typer.Exit(1) from ose
+        raise click.exceptions.Exit(1) from ose
 
     # Use httpx GET with redirects to download the zip file.
     zip_file_path: Path = Path(temp_dir) / "template.zip"
@@ -1714,20 +1720,20 @@ def create_config_init_app_from_remote_template(app_name: str, template_url: str
         response.raise_for_status()
     except httpx.HTTPError as he:
         console.error(f"Failed to download the template: {he}")
-        raise typer.Exit(1) from he
+        raise click.exceptions.Exit(1) from he
     try:
         zip_file_path.write_bytes(response.content)
         console.debug(f"Downloaded the zip to {zip_file_path}")
     except OSError as ose:
         console.error(f"Unable to write the downloaded zip to disk {ose}")
-        raise typer.Exit(1) from ose
+        raise click.exceptions.Exit(1) from ose
 
     # Create a temp directory for the zip extraction.
     try:
         unzip_dir = Path(tempfile.mkdtemp())
     except OSError as ose:
         console.error(f"Failed to create temp directory for extracting zip: {ose}")
-        raise typer.Exit(1) from ose
+        raise click.exceptions.Exit(1) from ose
 
     try:
         zipfile.ZipFile(zip_file_path).extractall(path=unzip_dir)
@@ -1735,11 +1741,11 @@ def create_config_init_app_from_remote_template(app_name: str, template_url: str
         # repo-name-branch/**/*, so we need to remove the top level directory.
     except Exception as uze:
         console.error(f"Failed to unzip the template: {uze}")
-        raise typer.Exit(1) from uze
+        raise click.exceptions.Exit(1) from uze
 
     if len(subdirs := list(unzip_dir.iterdir())) != 1:
         console.error(f"Expected one directory in the zip, found {subdirs}")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     template_dir = unzip_dir / subdirs[0]
     console.debug(f"Template folder is located at {template_dir}")
@@ -1801,7 +1807,7 @@ def validate_and_create_app_using_remote_template(
             console.print(
                 f"Please use `reflex login` to access the '{template}' template."
             )
-            raise typer.Exit(3)
+            raise click.exceptions.Exit(3)
 
         template_url = templates[template].code_url
     else:
@@ -1812,7 +1818,7 @@ def validate_and_create_app_using_remote_template(
             template_url = f"https://github.com/{path}/archive/main.zip"
         else:
             console.error(f"Template `{template}` not found or invalid.")
-            raise typer.Exit(1)
+            raise click.exceptions.Exit(1)
 
     if template_url is None:
         return
@@ -1879,7 +1885,7 @@ def initialize_app(app_name: str, template: str | None = None) -> str | None:
             console.print(
                 f"Go to the templates page ({constants.Templates.REFLEX_TEMPLATES_URL}) and copy the command to init with a template."
             )
-            raise typer.Exit(0)
+            raise click.exceptions.Exit(0)
 
     # If the blank template is selected, create a blank app.
     if template in (constants.Templates.DEFAULT,):

+ 6 - 6
reflex/utils/processes.py

@@ -13,8 +13,8 @@ from concurrent import futures
 from pathlib import Path
 from typing import Any, Literal, overload
 
+import click
 import psutil
-import typer
 from redis.exceptions import RedisError
 from rich.progress import Progress
 
@@ -48,7 +48,7 @@ def get_num_workers() -> int:
         redis_client.ping()
     except RedisError as re:
         console.error(f"Unable to connect to Redis: {re}")
-        raise typer.Exit(1) from re
+        raise click.exceptions.Exit(1) from re
     return (os.cpu_count() or 1) * 2 + 1
 
 
@@ -141,7 +141,7 @@ def handle_port(service_name: str, port: int, auto_increment: bool) -> int:
         console.error(
             f"{service_name.capitalize()} port: {port} is already in use by PID: {process.pid}."
         )
-        raise typer.Exit()
+        raise click.exceptions.Exit()
 
 
 @overload
@@ -186,7 +186,7 @@ def new_process(
     non_empty_args = list(filter(None, args)) if isinstance(args, list) else [args]
     if isinstance(args, list) and len(non_empty_args) != len(args):
         console.error(f"Invalid command: {args}")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
     path_env: str = os.environ.get("PATH", "")
 
@@ -345,7 +345,7 @@ def stream_logs(
                 "NPM_CONFIG_REGISTRY environment variable. If TLS is the issue, and you know what "
                 "you are doing, you can disable it by setting the SSL_NO_VERIFY environment variable."
             )
-            raise typer.Exit(1)
+            raise click.exceptions.Exit(1)
         for set_of_logs in (*prior_logs, tuple(logs)):
             for line in set_of_logs:
                 console.error(line, end="")
@@ -353,7 +353,7 @@ def stream_logs(
         if analytics_enabled:
             telemetry.send("error", context=message)
         console.error("Run with [bold]--loglevel debug [/bold] for the full log.")
-        raise typer.Exit(1)
+        raise click.exceptions.Exit(1)
 
 
 def show_logs(message: str, process: subprocess.Popen):

+ 5 - 4
reflex/utils/pyi_generator.py

@@ -1264,7 +1264,7 @@ class PyiGenerator:
                         dict(
                             zip(
                                 [
-                                    str(f.relative_to(pyi_hashes_file.parent))
+                                    f.relative_to(pyi_hashes_file.parent).as_posix()
                                     for f in file_paths
                                 ],
                                 hashes,
@@ -1291,9 +1291,10 @@ class PyiGenerator:
                     for file_path, hashed_content in zip(
                         file_paths, hashes, strict=False
                     ):
-                        pyi_hashes[str(file_path.relative_to(pyi_hashes_parent))] = (
-                            hashed_content
-                        )
+                        formatted_path = file_path.relative_to(
+                            pyi_hashes_parent
+                        ).as_posix()
+                        pyi_hashes[formatted_path] = hashed_content
 
                     pyi_hashes_file.write_text(
                         json.dumps(pyi_hashes, indent=2, sort_keys=True) + "\n"

+ 11 - 6
reflex/utils/types.py

@@ -11,11 +11,13 @@ from types import GenericAlias
 from typing import (  # noqa: UP035
     TYPE_CHECKING,
     Any,
+    Awaitable,
     ClassVar,
     Dict,
     ForwardRef,
     List,
     Literal,
+    MutableMapping,
     NoReturn,
     Tuple,
     Union,
@@ -73,6 +75,13 @@ if TYPE_CHECKING:
 else:
     ArgsSpec = Callable[..., list[Any]]
 
+Scope = MutableMapping[str, Any]
+Message = MutableMapping[str, Any]
+
+Receive = Callable[[], Awaitable[Message]]
+Send = Callable[[Message], Awaitable[None]]
+
+ASGIApp = Callable[[Scope, Receive, Send], Awaitable[None]]
 
 PrimitiveToAnnotation = {
     list: List,  # noqa: UP006
@@ -149,16 +158,12 @@ def get_type_hints(obj: Any) -> dict[str, Any]:
     return get_type_hints_og(obj)
 
 
-def _unionize(args: list[GenericType]) -> type:
+def _unionize(args: list[GenericType]) -> GenericType:
     if not args:
         return Any  # pyright: ignore [reportReturnType]
     if len(args) == 1:
         return args[0]
-    # We are bisecting the args list here to avoid hitting the recursion limit
-    # In Python versions >= 3.11, we can simply do `return Union[*args]`
-    midpoint = len(args) // 2
-    first_half, second_half = args[:midpoint], args[midpoint:]
-    return Union[unionize(*first_half), unionize(*second_half)]  # pyright: ignore [reportReturnType]  # noqa: UP007
+    return Union[tuple(args)]  # noqa: UP007
 
 
 def unionize(*args: GenericType) -> type:

+ 4 - 0
reflex/vars/base.py

@@ -1782,6 +1782,10 @@ def figure_out_type(value: Any) -> types.GenericType:
     if isinstance(value, set):
         return set[unionize(*(figure_out_type(v) for v in value))]
     if isinstance(value, tuple):
+        if not value:
+            return tuple[NoReturn, ...]
+        if len(value) <= 5:
+            return tuple[tuple(figure_out_type(v) for v in value)]
         return tuple[unionize(*(figure_out_type(v) for v in value)), ...]
     if isinstance(value, Mapping):
         if not value:

+ 1 - 1
tests/integration/test_lifespan.py

@@ -1,4 +1,4 @@
-"""Test cases for the FastAPI lifespan integration."""
+"""Test cases for the Starlette lifespan integration."""
 
 from collections.abc import Generator
 

+ 81 - 9
tests/integration/test_upload.py

@@ -26,27 +26,32 @@ def UploadFile():
     class UploadState(rx.State):
         _file_data: dict[str, str] = {}
         event_order: rx.Field[list[str]] = rx.field([])
-        progress_dicts: list[dict] = []
-        disabled: bool = False
-        large_data: str = ""
+        progress_dicts: rx.Field[list[dict]] = rx.field([])
+        disabled: rx.Field[bool] = rx.field(False)
+        large_data: rx.Field[str] = rx.field("")
+        quaternary_names: rx.Field[list[str]] = rx.field([])
 
+        @rx.event
         async def handle_upload(self, files: list[rx.UploadFile]):
             for file in files:
                 upload_data = await file.read()
-                self._file_data[file.filename or ""] = upload_data.decode("utf-8")
+                self._file_data[file.name or ""] = upload_data.decode("utf-8")
 
+        @rx.event
         async def handle_upload_secondary(self, files: list[rx.UploadFile]):
             for file in files:
                 upload_data = await file.read()
-                self._file_data[file.filename or ""] = upload_data.decode("utf-8")
+                self._file_data[file.name or ""] = upload_data.decode("utf-8")
                 self.large_data = LARGE_DATA
                 yield UploadState.chain_event
 
+        @rx.event
         def upload_progress(self, progress):
             assert progress
             self.event_order.append("upload_progress")
             self.progress_dicts.append(progress)
 
+        @rx.event
         def chain_event(self):
             assert self.large_data == LARGE_DATA
             self.large_data = ""
@@ -55,10 +60,14 @@ def UploadFile():
         @rx.event
         async def handle_upload_tertiary(self, files: list[rx.UploadFile]):
             for file in files:
-                (rx.get_upload_dir() / (file.filename or "INVALID")).write_bytes(
+                (rx.get_upload_dir() / (file.name or "INVALID")).write_bytes(
                     await file.read()
                 )
 
+        @rx.event
+        async def handle_upload_quaternary(self, files: list[rx.UploadFile]):
+            self.quaternary_names = [file.name for file in files if file.name]
+
         @rx.event
         def do_download(self):
             return rx.download(rx.get_upload_url("test.txt"))
@@ -80,7 +89,7 @@ def UploadFile():
             ),
             rx.button(
                 "Upload",
-                on_click=lambda: UploadState.handle_upload(rx.upload_files()),  # pyright: ignore [reportCallIssue]
+                on_click=lambda: UploadState.handle_upload(rx.upload_files()),  # pyright: ignore [reportArgumentType]
                 id="upload_button",
             ),
             rx.box(
@@ -105,8 +114,8 @@ def UploadFile():
             ),
             rx.button(
                 "Upload",
-                on_click=UploadState.handle_upload_secondary(  # pyright: ignore [reportCallIssue]
-                    rx.upload_files(
+                on_click=UploadState.handle_upload_secondary(
+                    rx.upload_files(  # pyright: ignore [reportArgumentType]
                         upload_id="secondary",
                         on_upload_progress=UploadState.upload_progress,
                     ),
@@ -163,6 +172,22 @@ def UploadFile():
                 on_click=UploadState.do_download,
                 id="download-backend",
             ),
+            rx.upload.root(
+                rx.vstack(
+                    rx.button("Select File"),
+                    rx.text("Drag and drop files here or click to select files"),
+                ),
+                on_drop=UploadState.handle_upload_quaternary(
+                    rx.upload_files(  # pyright: ignore [reportArgumentType]
+                        upload_id="quaternary",
+                    ),
+                ),
+                id="quaternary",
+            ),
+            rx.text(
+                UploadState.quaternary_names.to_string(),
+                id="quaternary_files",
+            ),
             rx.text(UploadState.event_order.to_string(), id="event-order"),
         )
 
@@ -501,3 +526,50 @@ async def test_upload_download_file(
         download_backend.click()
     assert urlsplit(driver.current_url).path == f"/{Endpoint.UPLOAD.value}/test.txt"
     assert driver.find_element(by=By.TAG_NAME, value="body").text == exp_contents
+
+
+@pytest.mark.asyncio
+async def test_on_drop(
+    tmp_path,
+    upload_file: AppHarness,
+    driver: WebDriver,
+):
+    """Test the on_drop event handler.
+
+    Args:
+        tmp_path: pytest tmp_path fixture
+        upload_file: harness for UploadFile app.
+        driver: WebDriver instance.
+    """
+    assert upload_file.app_instance is not None
+    token = poll_for_token(driver, upload_file)
+    full_state_name = upload_file.get_full_state_name(["_upload_state"])
+    state_name = upload_file.get_state_name("_upload_state")
+    substate_token = f"{token}_{full_state_name}"
+
+    upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[
+        3
+    ]  # quaternary upload
+    assert upload_box
+
+    exp_name = "drop_test.txt"
+    exp_contents = "dropped file contents!"
+    target_file = tmp_path / exp_name
+    target_file.write_text(exp_contents)
+
+    # Simulate file drop by directly setting the file input
+    upload_box.send_keys(str(target_file))
+
+    # Wait for the on_drop event to be processed
+    await asyncio.sleep(0.5)
+
+    async def exp_name_in_quaternary():
+        state = await upload_file.get_state(substate_token)
+        return exp_name in state.substates[state_name].quaternary_names
+
+    # Poll until the file names appear in the display
+    await AppHarness._poll_for_async(exp_name_in_quaternary)
+
+    # Verify through state that the file names were captured correctly
+    state = await upload_file.get_state(substate_token)
+    assert exp_name in state.substates[state_name].quaternary_names

+ 9 - 5
tests/units/components/test_component.py

@@ -5,10 +5,11 @@ import pytest
 
 import reflex as rx
 from reflex.base import Base
-from reflex.compiler.compiler import compile_components
+from reflex.compiler.utils import compile_custom_component
 from reflex.components.base.bare import Bare
 from reflex.components.base.fragment import Fragment
 from reflex.components.component import (
+    CUSTOM_COMPONENTS,
     Component,
     CustomComponent,
     StatefulComponent,
@@ -877,7 +878,7 @@ def test_create_custom_component(my_component):
     component = rx.memo(my_component)(prop1="test", prop2=1)
     assert component.tag == "MyComponent"
     assert component.get_props() == {"prop1", "prop2"}
-    assert component._get_all_custom_components() == {component}
+    assert component.tag in CUSTOM_COMPONENTS
 
 
 def test_custom_component_hash(my_component):
@@ -1801,10 +1802,13 @@ def test_custom_component_get_imports():
 
     # Inner is not imported directly, but it is imported by the custom component.
     assert "inner" not in custom_comp._get_all_imports()
+    assert "outer" not in custom_comp._get_all_imports()
 
     # The imports are only resolved during compilation.
-    _, _, imports_inner = compile_components(custom_comp._get_all_custom_components())
+    custom_comp.get_component(custom_comp)
+    _, imports_inner = compile_custom_component(custom_comp)
     assert "inner" in imports_inner
+    assert "outer" not in imports_inner
 
     outer_comp = outer(c=wrapper())
 
@@ -1813,8 +1817,8 @@ def test_custom_component_get_imports():
     assert "other" not in outer_comp._get_all_imports()
 
     # The imports are only resolved during compilation.
-    _, _, imports_outer = compile_components(outer_comp._get_all_custom_components())
-    assert "inner" in imports_outer
+    _, imports_outer = compile_custom_component(outer_comp)
+    assert "inner" not in imports_outer
     assert "other" in imports_outer
 
 

+ 9 - 9
tests/units/states/upload.py

@@ -59,14 +59,14 @@ class FileUploadState(State):
         """
         for file in files:
             upload_data = await file.read()
-            assert file.filename is not None
-            outfile = self._tmp_path / file.filename
+            assert file.name is not None
+            outfile = self._tmp_path / file.name
 
             # Save the file.
             outfile.write_bytes(upload_data)
 
             # Update the img var.
-            self.img_list.append(file.filename)
+            self.img_list.append(file.name)
 
     @rx.event(background=True)
     async def bg_upload(self, files: list[rx.UploadFile]):
@@ -106,14 +106,14 @@ class ChildFileUploadState(FileStateBase1):
         """
         for file in files:
             upload_data = await file.read()
-            assert file.filename is not None
-            outfile = self._tmp_path / file.filename
+            assert file.name is not None
+            outfile = self._tmp_path / file.name
 
             # Save the file.
             outfile.write_bytes(upload_data)
 
             # Update the img var.
-            self.img_list.append(file.filename)
+            self.img_list.append(file.name)
 
     @rx.event(background=True)
     async def bg_upload(self, files: list[rx.UploadFile]):
@@ -153,14 +153,14 @@ class GrandChildFileUploadState(FileStateBase2):
         """
         for file in files:
             upload_data = await file.read()
-            assert file.filename is not None
-            outfile = self._tmp_path / file.filename
+            assert file.name is not None
+            outfile = self._tmp_path / file.name
 
             # Save the file.
             outfile.write_bytes(upload_data)
 
             # Update the img var.
-            self.img_list.append(file.filename)
+            self.img_list.append(file.name)
 
     @rx.event(background=True)
     async def bg_upload(self, files: list[rx.UploadFile]):

+ 49 - 8
tests/units/test_app.py

@@ -14,8 +14,9 @@ from unittest.mock import AsyncMock
 
 import pytest
 import sqlmodel
-from fastapi import FastAPI, UploadFile
 from pytest_mock import MockerFixture
+from starlette.applications import Starlette
+from starlette.datastructures import UploadFile
 from starlette_admin.auth import AuthProvider
 from starlette_admin.contrib.sqla.admin import Admin
 from starlette_admin.contrib.sqla.view import ModelView
@@ -813,8 +814,22 @@ async def test_upload_file(tmp_path, state, delta, token: str, mocker):
         filename="image2.jpg",
         file=bio,
     )
+
+    async def form():
+        files_mock = unittest.mock.Mock()
+
+        def getlist(key: str):
+            assert key == "files"
+            return [file1, file2]
+
+        files_mock.getlist = getlist
+
+        return files_mock
+
+    request_mock.form = form
+
     upload_fn = upload(app)
-    streaming_response = await upload_fn(request_mock, [file1, file2])  # pyright: ignore [reportFunctionMemberAccess]
+    streaming_response = await upload_fn(request_mock)
     async for state_update in streaming_response.body_iterator:
         assert (
             state_update
@@ -853,10 +868,23 @@ async def test_upload_file_without_annotation(state, tmp_path, token):
         "reflex-client-token": token,
         "reflex-event-handler": f"{state.get_full_name()}.handle_upload2",
     }
-    file_mock = unittest.mock.Mock(filename="image1.jpg")
+
+    async def form():
+        files_mock = unittest.mock.Mock()
+
+        def getlist(key: str):
+            assert key == "files"
+            return [unittest.mock.Mock(filename="image1.jpg")]
+
+        files_mock.getlist = getlist
+
+        return files_mock
+
+    request_mock.form = form
+
     fn = upload(app)
     with pytest.raises(ValueError) as err:
-        await fn(request_mock, [file_mock])
+        await fn(request_mock)
     assert (
         err.value.args[0]
         == f"`{state.get_full_name()}.handle_upload2` handler should have a parameter annotated as list[rx.UploadFile]"
@@ -887,10 +915,23 @@ async def test_upload_file_background(state, tmp_path, token):
         "reflex-client-token": token,
         "reflex-event-handler": f"{state.get_full_name()}.bg_upload",
     }
-    file_mock = unittest.mock.Mock(filename="image1.jpg")
+
+    async def form():
+        files_mock = unittest.mock.Mock()
+
+        def getlist(key: str):
+            assert key == "files"
+            return [unittest.mock.Mock(filename="image1.jpg")]
+
+        files_mock.getlist = getlist
+
+        return files_mock
+
+    request_mock.form = form
+
     fn = upload(app)
     with pytest.raises(TypeError) as err:
-        await fn(request_mock, [file_mock])
+        await fn(request_mock)
     assert (
         err.value.args[0]
         == f"@rx.event(background=True) is not supported for upload handler `{state.get_full_name()}.bg_upload`."
@@ -1472,9 +1513,9 @@ def test_raise_on_state():
 def test_call_app():
     """Test that the app can be called."""
     app = App()
-    app._get_frontend_packages = unittest.mock.Mock()
+    app._compile = unittest.mock.Mock()
     api = app()
-    assert isinstance(api, FastAPI)
+    assert isinstance(api, Starlette)
 
 
 def test_app_with_optional_endpoints():

+ 3 - 1
tests/units/test_health_endpoint.py

@@ -119,8 +119,10 @@ async def test_health(
         return_value={"redis": redis_status},
     )
 
+    request = Mock()
+
     # Call the async health function
-    response = await health()
+    response = await health(request)
 
     # Verify the response content and status code
     assert response.status_code == expected_code

+ 2 - 2
tests/units/test_prerequisites.py

@@ -5,7 +5,7 @@ import tempfile
 from pathlib import Path
 
 import pytest
-from typer.testing import CliRunner
+from click.testing import CliRunner
 
 from reflex.config import Config
 from reflex.reflex import cli
@@ -279,7 +279,7 @@ app.add_page(index)
     with chdir(temp_directory / "foo"):
         result = runner.invoke(cli, ["rename", "bar"])
 
-    assert result.exit_code == 0
+    assert result.exit_code == 0, result.output
     assert (foo_dir / "rxconfig.py").read_text() == (
         """
 import reflex as rx

+ 5 - 5
tests/units/utils/test_utils.py

@@ -5,8 +5,8 @@ from functools import cached_property
 from pathlib import Path
 from typing import Any, ClassVar, List, Literal, NoReturn  # noqa: UP035
 
+import click
 import pytest
-import typer
 from packaging import version
 
 from reflex import constants
@@ -180,7 +180,7 @@ def test_validate_none_bun_path(mocker):
         mocker: Pytest mocker object.
     """
     mocker.patch("reflex.utils.path_ops.get_bun_path", return_value=None)
-    # with pytest.raises(typer.Exit):
+    # with pytest.raises(click.exceptions.Exit):
     prerequisites.validate_bun()
 
 
@@ -198,7 +198,7 @@ def test_validate_invalid_bun_path(
     mocker.patch("reflex.utils.path_ops.samefile", return_value=False)
     mocker.patch("reflex.utils.prerequisites.get_bun_version", return_value=None)
 
-    with pytest.raises(typer.Exit):
+    with pytest.raises(click.exceptions.Exit):
         prerequisites.validate_bun()
 
 
@@ -464,10 +464,10 @@ def test_validate_app_name(tmp_path, mocker):
 
     mocker.patch("reflex.utils.prerequisites.os.getcwd", return_value=str(reflex))
 
-    with pytest.raises(typer.Exit):
+    with pytest.raises(click.exceptions.Exit):
         prerequisites.validate_app_name()
 
-    with pytest.raises(typer.Exit):
+    with pytest.raises(click.exceptions.Exit):
         prerequisites.validate_app_name(app_name="1_test")
 
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 445 - 457
uv.lock


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů