diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 106ac1383..7717ef265 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -122,7 +122,7 @@ jobs: fail-fast: false matrix: # Show OS combos first in GUI - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest] python-version: ['3.10.11', '3.11.4'] env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2983d1d1..60cbec00f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ fail_fast: true repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.7.0 + rev: v0.7.1 hooks: - id: ruff-format args: [reflex, tests] diff --git a/poetry.lock b/poetry.lock index 71be30d76..baf9ebb69 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1348,8 +1348,8 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, ] python-dateutil = ">=2.8.2" @@ -1667,8 +1667,8 @@ files = [ annotated-types = ">=0.6.0" pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.6.1", markers = "python_version < \"3.13\""}, {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, ] [package.extras] @@ -2164,13 +2164,13 @@ md = ["cmarkgfm (>=0.8.0)"] [[package]] name = "redis" -version = "5.1.1" +version = "5.2.0" description = "Python client for Redis database and key-value store" optional = false python-versions = ">=3.8" files = [ - {file = "redis-5.1.1-py3-none-any.whl", hash = "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24"}, - {file = "redis-5.1.1.tar.gz", hash = "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72"}, + {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, + {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, ] [package.dependencies] @@ -2287,29 +2287,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.7.0" +version = "0.7.1" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.0-py3-none-linux_armv6l.whl", hash = "sha256:0cdf20c2b6ff98e37df47b2b0bd3a34aaa155f59a11182c1303cce79be715628"}, - {file = "ruff-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:496494d350c7fdeb36ca4ef1c9f21d80d182423718782222c29b3e72b3512737"}, - {file = "ruff-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:214b88498684e20b6b2b8852c01d50f0651f3cc6118dfa113b4def9f14faaf06"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630fce3fefe9844e91ea5bbf7ceadab4f9981f42b704fae011bb8efcaf5d84be"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:211d877674e9373d4bb0f1c80f97a0201c61bcd1e9d045b6e9726adc42c156aa"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:194d6c46c98c73949a106425ed40a576f52291c12bc21399eb8f13a0f7073495"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82c2579b82b9973a110fab281860403b397c08c403de92de19568f32f7178598"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9af971fe85dcd5eaed8f585ddbc6bdbe8c217fb8fcf510ea6bca5bdfff56040e"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b641c7f16939b7d24b7bfc0be4102c56562a18281f84f635604e8a6989948914"}, - {file = "ruff-0.7.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71672336e46b34e0c90a790afeac8a31954fd42872c1f6adaea1dff76fd44f9"}, - {file = "ruff-0.7.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ab7d98c7eed355166f367597e513a6c82408df4181a937628dbec79abb2a1fe4"}, - {file = "ruff-0.7.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1eb54986f770f49edb14f71d33312d79e00e629a57387382200b1ef12d6a4ef9"}, - {file = "ruff-0.7.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dc452ba6f2bb9cf8726a84aa877061a2462afe9ae0ea1d411c53d226661c601d"}, - {file = "ruff-0.7.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4b406c2dce5be9bad59f2de26139a86017a517e6bcd2688da515481c05a2cb11"}, - {file = "ruff-0.7.0-py3-none-win32.whl", hash = "sha256:f6c968509f767776f524a8430426539587d5ec5c662f6addb6aa25bc2e8195ec"}, - {file = "ruff-0.7.0-py3-none-win_amd64.whl", hash = "sha256:ff4aabfbaaba880e85d394603b9e75d32b0693152e16fa659a3064a85df7fce2"}, - {file = "ruff-0.7.0-py3-none-win_arm64.whl", hash = "sha256:10842f69c245e78d6adec7e1db0a7d9ddc2fff0621d730e61657b64fa36f207e"}, - {file = "ruff-0.7.0.tar.gz", hash = "sha256:47a86360cf62d9cd53ebfb0b5eb0e882193fc191c6d717e8bef4462bc3b9ea2b"}, + {file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"}, + {file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"}, + {file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"}, + {file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"}, + {file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"}, + {file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"}, + {file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"}, ] [[package]] @@ -3048,4 +3048,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "e03374b85bf10f0a7bb857969b2d6714f25affa63e14a48a88be9fa154b24326" +content-hash = "547fdabf7a030c2a7c8d63eb5b2a3c5e821afa86390f08b895db038d30013904" diff --git a/pyproject.toml b/pyproject.toml index 2635e1156..511ac9a7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "reflex" -version = "0.6.4dev1" +version = "0.6.5dev1" description = "Web apps in pure Python." license = "Apache-2.0" authors = [ @@ -69,7 +69,7 @@ dill = ">=0.3.8" toml = ">=0.10.2,<1.0" pytest-asyncio = ">=0.24.0" pytest-cov = ">=4.0.0,<6.0" -ruff = "^0.7.0" +ruff = "0.7.1" pandas = ">=2.1.1,<3.0" pillow = ">=10.0.0,<12.0" plotly = ">=5.13.0,<6.0" @@ -104,4 +104,4 @@ lint.pydocstyle.convention = "google" [tool.pytest.ini_options] asyncio_default_fixture_loop_scope = "function" -asyncio_mode = "auto" \ No newline at end of file +asyncio_mode = "auto" diff --git a/reflex/app.py b/reflex/app.py index e524d40ad..5923e3389 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -6,6 +6,7 @@ import asyncio import concurrent.futures import contextlib import copy +import dataclasses import functools import inspect import io @@ -18,6 +19,7 @@ import traceback from datetime import datetime from pathlib import Path from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Callable, @@ -47,7 +49,10 @@ from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin from reflex.base import Base from reflex.compiler import compiler from reflex.compiler import utils as compiler_utils -from reflex.compiler.compiler import ExecutorSafeFunctions +from reflex.compiler.compiler import ( + ExecutorSafeFunctions, + compile_theme, +) from reflex.components.base.app_wrap import AppWrap from reflex.components.base.error_boundary import ErrorBoundary from reflex.components.base.fragment import Fragment @@ -88,6 +93,9 @@ from reflex.utils import codespaces, console, exceptions, format, prerequisites, from reflex.utils.exec import is_prod_mode, is_testing_env, should_skip_compile from reflex.utils.imports import ImportVar +if TYPE_CHECKING: + from reflex.vars import Var + # Define custom types. ComponentCallable = Callable[[], Component] Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]] @@ -170,6 +178,21 @@ class OverlayFragment(Fragment): pass +@dataclasses.dataclass( + frozen=True, +) +class UnevaluatedPage: + """An uncompiled page.""" + + component: Union[Component, ComponentCallable] + route: str + title: Union[Var, str, None] + description: Union[Var, str, None] + image: str + on_load: Union[EventHandler, EventSpec, List[Union[EventHandler, EventSpec]], None] + meta: List[Dict[str, str]] + + class App(MiddlewareMixin, LifespanMixin, Base): """The main Reflex app that encapsulates the backend and frontend. @@ -220,6 +243,9 @@ class App(MiddlewareMixin, LifespanMixin, Base): # Attributes to add to the html root tag of every page. html_custom_attrs: Optional[Dict[str, str]] = None + # A map from a route to an unevaluated page. PRIVATE. + unevaluated_pages: Dict[str, UnevaluatedPage] = {} + # A map from a page route to the component to render. Users should use `add_page`. PRIVATE. pages: Dict[str, Component] = {} @@ -381,8 +407,8 @@ class App(MiddlewareMixin, LifespanMixin, Base): def _add_optional_endpoints(self): """Add optional api endpoints (_upload).""" - # To upload files. if Upload.is_used: + # To upload files. self.api.post(str(constants.Endpoint.UPLOAD))(upload(self)) # To access uploaded files. @@ -442,8 +468,8 @@ class App(MiddlewareMixin, LifespanMixin, Base): self, component: Component | ComponentCallable, route: str | None = None, - title: str | None = None, - description: str | None = None, + title: str | Var | None = None, + description: str | Var | None = None, image: str = constants.DefaultPage.IMAGE, on_load: ( EventHandler | EventSpec | list[EventHandler | EventSpec] | None @@ -479,13 +505,13 @@ class App(MiddlewareMixin, LifespanMixin, Base): # Check if the route given is valid verify_route_validity(route) - if route in self.pages and os.getenv(constants.RELOAD_CONFIG): + if route in self.unevaluated_pages and os.getenv(constants.RELOAD_CONFIG): # when the app is reloaded(typically for app harness tests), we should maintain # the latest render function of a route.This applies typically to decorated pages # since they are only added when app._compile is called. - self.pages.pop(route) + self.unevaluated_pages.pop(route) - if route in self.pages: + if route in self.unevaluated_pages: route_name = ( f"`{route}` or `/`" if route == constants.PageNames.INDEX_ROUTE @@ -501,58 +527,38 @@ class App(MiddlewareMixin, LifespanMixin, Base): state = self.state if self.state else State state.setup_dynamic_args(get_route_args(route)) - # Generate the component if it is a callable. - component = self._generate_component(component) + if on_load: + self.load_events[route] = ( + on_load if isinstance(on_load, list) else [on_load] + ) - # unpack components that return tuples in an rx.fragment. - if isinstance(component, tuple): - component = Fragment.create(*component) - - # Ensure state is enabled if this page uses state. - if self.state is None: - if on_load or component._has_stateful_event_triggers(): - self._enable_state() - else: - for var in component._get_vars(include_children=True): - var_data = var._get_all_var_data() - if not var_data: - continue - if not var_data.state: - continue - self._enable_state() - break - - component = OverlayFragment.create(component) - - meta_args = { - "title": ( - title - if title is not None - else format.make_default_page_title(get_config().app_name, route) - ), - "image": image, - "meta": meta, - } - - if description is not None: - meta_args["description"] = description - - # Add meta information to the component. - compiler_utils.add_meta( - component, - **meta_args, + self.unevaluated_pages[route] = UnevaluatedPage( + component=component, + route=route, + title=title, + description=description, + image=image, + on_load=on_load, + meta=meta, ) + def _compile_page(self, route: str): + """Compile a page. + + Args: + route: The route of the page to compile. + """ + component, enable_state = compiler.compile_unevaluated_page( + route, self.unevaluated_pages[route], self.state, self.style, self.theme + ) + + if enable_state: + self._enable_state() + # Add the page. self._check_routes_conflict(route) self.pages[route] = component - # Add the load events. - if on_load: - if not isinstance(on_load, list): - on_load = [on_load] - self.load_events[route] = on_load - def get_load_events(self, route: str) -> list[EventHandler | EventSpec]: """Get the load events for a route. @@ -827,13 +833,33 @@ class App(MiddlewareMixin, LifespanMixin, Base): """ from reflex.utils.exceptions import ReflexRuntimeError + self.pages = {} + def get_compilation_time() -> str: return str(datetime.now().time()).split(".")[0] # Render a default 404 page if the user didn't supply one - if constants.Page404.SLUG not in self.pages: + if constants.Page404.SLUG not in self.unevaluated_pages: self.add_custom_404_page() + # Fix up the style. + self.style = evaluate_style_namespaces(self.style) + + # Add the app wrappers. + app_wrappers: Dict[tuple[int, str], Component] = { + # Default app wrap component renders {children} + (0, "AppWrap"): AppWrap.create() + } + + if self.theme is not None: + # If a theme component was provided, wrap the app with it + app_wrappers[(20, "Theme")] = self.theme + # Fix #2992 by removing the top-level appearance prop + self.theme.appearance = None + + for route in self.unevaluated_pages: + self._compile_page(route) + # Add the optional endpoints (_upload) self._add_optional_endpoints() @@ -868,28 +894,15 @@ class App(MiddlewareMixin, LifespanMixin, Base): # Store the compile results. compile_results = [] - # Add the app wrappers. - app_wrappers: Dict[tuple[int, str], Component] = { - # Default app wrap component renders {children} - (0, "AppWrap"): AppWrap.create() - } - if self.theme is not None: - # If a theme component was provided, wrap the app with it - app_wrappers[(20, "Theme")] = self.theme - progress.advance(task) - # Fix up the style. - self.style = evaluate_style_namespaces(self.style) - # Track imports and custom components found. all_imports = {} custom_components = set() - for _route, component in self.pages.items(): - # Merge the component style with the app style. - component._add_style_recursive(self.style, self.theme) - + # This has to happen before compiling stateful components as that + # prevents recursive functions from reaching all components. + for component in self.pages.values(): # Add component._get_all_imports() to all_imports. all_imports.update(component._get_all_imports()) @@ -899,8 +912,6 @@ class App(MiddlewareMixin, LifespanMixin, Base): # Add the custom components from the page to the set. custom_components |= component._get_all_custom_components() - progress.advance(task) - # Perform auto-memoization of stateful components. ( stateful_components_path, @@ -927,31 +938,8 @@ class App(MiddlewareMixin, LifespanMixin, Base): ) ) - # Compile the contexts before fork. - compile_results.append( - compiler.compile_contexts(self.state, self.theme), - ) - # Fix #2992 by removing the top-level appearance prop - if self.theme is not None: - self.theme.appearance = None - - app_root = self._app_root(app_wrappers=app_wrappers) - progress.advance(task) - # Prepopulate the global ExecutorSafeFunctions class with input data required by the compile functions. - # This is required for multiprocessing to work, in presence of non-picklable inputs. - for route, component in zip(self.pages, page_components): - ExecutorSafeFunctions.COMPILE_PAGE_ARGS_BY_ROUTE[route] = ( - route, - component, - self.state, - ) - - ExecutorSafeFunctions.COMPILE_APP_APP_ROOT = app_root - ExecutorSafeFunctions.CUSTOM_COMPONENTS = custom_components - ExecutorSafeFunctions.STYLE = self.style - # Use a forking process pool, if possible. Much faster, especially for large sites. # Fallback to ThreadPoolExecutor as something that will always work. executor = None @@ -969,36 +957,31 @@ class App(MiddlewareMixin, LifespanMixin, Base): max_workers=environment.REFLEX_COMPILE_THREADS ) + for route, component in zip(self.pages, page_components): + ExecutorSafeFunctions.COMPONENTS[route] = component + + ExecutorSafeFunctions.STATE = self.state + with executor: result_futures = [] - custom_components_future = None - - def _mark_complete(_=None): - progress.advance(task) def _submit_work(fn, *args, **kwargs): f = executor.submit(fn, *args, **kwargs) - f.add_done_callback(_mark_complete) + # f = executor.apipe(fn, *args, **kwargs) result_futures.append(f) - # Compile all page components. + # Compile the pre-compiled pages. for route in self.pages: - _submit_work(ExecutorSafeFunctions.compile_page, route) - - # Compile the app wrapper. - _submit_work(ExecutorSafeFunctions.compile_app) - - # Compile the custom components. - custom_components_future = executor.submit( - ExecutorSafeFunctions.compile_custom_components, - ) - custom_components_future.add_done_callback(_mark_complete) + _submit_work( + ExecutorSafeFunctions.compile_page, + route, + ) # Compile the root stylesheet with base styles. _submit_work(compiler.compile_root_stylesheet, self.stylesheets) # Compile the theme. - _submit_work(ExecutorSafeFunctions.compile_theme) + _submit_work(compile_theme, self.style) # Compile the Tailwind config. if config.tailwind is not None: @@ -1012,21 +995,34 @@ class App(MiddlewareMixin, LifespanMixin, Base): # Wait for all compilation tasks to complete. for future in concurrent.futures.as_completed(result_futures): compile_results.append(future.result()) + progress.advance(task) - # Special case for custom_components, since we need the compiled imports - # to install proper frontend packages. - ( - *custom_components_result, - custom_components_imports, - ) = custom_components_future.result() - compile_results.append(custom_components_result) - all_imports.update(custom_components_imports) + app_root = self._app_root(app_wrappers=app_wrappers) # Get imports from AppWrap components. all_imports.update(app_root._get_all_imports()) progress.advance(task) + # Compile the contexts. + compile_results.append( + compiler.compile_contexts(self.state, self.theme), + ) + progress.advance(task) + + # Compile the app root. + compile_results.append( + compiler.compile_app(app_root), + ) + progress.advance(task) + + # Compile custom components. + *custom_components_result, custom_components_imports = ( + compiler.compile_components(custom_components) + ) + compile_results.append(custom_components_result) + all_imports.update(custom_components_imports) + progress.advance(task) progress.stop() diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 4db4679d5..e9d56b7e7 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -4,10 +4,11 @@ from __future__ import annotations from datetime import datetime from pathlib import Path -from typing import Dict, Iterable, Optional, Type, Union +from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple, Type, Union from reflex import constants from reflex.compiler import templates, utils +from reflex.components.base.fragment import Fragment from reflex.components.component import ( BaseComponent, Component, @@ -126,8 +127,8 @@ def _compile_contexts(state: Optional[Type[BaseState]], theme: Component | None) def _compile_page( - component: Component, - state: Type[BaseState], + component: BaseComponent, + state: Type[BaseState] | None, ) -> str: """Compile the component given the app state. @@ -142,7 +143,7 @@ def _compile_page( imports = utils.compile_imports(imports) # Compile the code to render the component. - kwargs = {"state_name": state.get_name()} if state else {} + kwargs = {"state_name": state.get_name()} if state is not None else {} return templates.PAGE.render( imports=imports, @@ -424,7 +425,7 @@ def compile_contexts( def compile_page( - path: str, component: Component, state: Type[BaseState] + path: str, component: BaseComponent, state: Type[BaseState] | None ) -> tuple[str, str]: """Compile a single page. @@ -534,6 +535,81 @@ def purge_web_pages_dir(): utils.empty_dir(get_web_dir() / constants.Dirs.PAGES, keep_files=["_app.js"]) +if TYPE_CHECKING: + from reflex.app import UnevaluatedPage + + +def compile_unevaluated_page( + route: str, + page: UnevaluatedPage, + state: Type[BaseState] | None = None, + style: ComponentStyle | None = None, + theme: Component | None = None, +) -> Tuple[Component, bool]: + """Compiles an uncompiled page into a component and adds meta information. + + Args: + route: The route of the page. + page: The uncompiled page object. + state: The state of the app. + style: The style of the page. + theme: The theme of the page. + + Returns: + The compiled component and whether state should be enabled. + """ + # Generate the component if it is a callable. + component = page.component + component = component if isinstance(component, Component) else component() + + # unpack components that return tuples in an rx.fragment. + if isinstance(component, tuple): + component = Fragment.create(*component) + + component._add_style_recursive(style or {}, theme) + + enable_state = False + # Ensure state is enabled if this page uses state. + if state is None: + if page.on_load or component._has_stateful_event_triggers(): + enable_state = True + else: + for var in component._get_vars(include_children=True): + var_data = var._get_all_var_data() + if not var_data: + continue + if not var_data.state: + continue + enable_state = True + break + + from reflex.app import OverlayFragment + from reflex.utils.format import make_default_page_title + + component = OverlayFragment.create(component) + + meta_args = { + "title": ( + page.title + if page.title is not None + else make_default_page_title(get_config().app_name, route) + ), + "image": page.image, + "meta": page.meta, + } + + if page.description is not None: + meta_args["description"] = page.description + + # Add meta information to the component. + utils.add_meta( + component, + **meta_args, + ) + + return component, enable_state + + class ExecutorSafeFunctions: """Helper class to allow parallelisation of parts of the compilation process. @@ -559,13 +635,12 @@ class ExecutorSafeFunctions: """ - COMPILE_PAGE_ARGS_BY_ROUTE = {} - COMPILE_APP_APP_ROOT: Component | None = None - CUSTOM_COMPONENTS: set[CustomComponent] | None = None - STYLE: ComponentStyle | None = None + COMPONENTS: Dict[str, BaseComponent] = {} + UNCOMPILED_PAGES: Dict[str, UnevaluatedPage] = {} + STATE: Optional[Type[BaseState]] = None @classmethod - def compile_page(cls, route: str): + def compile_page(cls, route: str) -> tuple[str, str]: """Compile a page. Args: @@ -574,46 +649,45 @@ class ExecutorSafeFunctions: Returns: The path and code of the compiled page. """ - return compile_page(*cls.COMPILE_PAGE_ARGS_BY_ROUTE[route]) + return compile_page(route, cls.COMPONENTS[route], cls.STATE) @classmethod - def compile_app(cls): - """Compile the app. + def compile_unevaluated_page( + cls, + route: str, + style: ComponentStyle, + theme: Component | None, + ) -> tuple[str, Component, tuple[str, str]]: + """Compile an unevaluated page. + + Args: + route: The route of the page to compile. + style: The style of the page. + theme: The theme of the page. Returns: - The path and code of the compiled app. - - Raises: - ValueError: If the app root is not set. + The route, compiled component, and compiled page. """ - if cls.COMPILE_APP_APP_ROOT is None: - raise ValueError("COMPILE_APP_APP_ROOT should be set") - return compile_app(cls.COMPILE_APP_APP_ROOT) + component, enable_state = compile_unevaluated_page( + route, cls.UNCOMPILED_PAGES[route] + ) + component = component if isinstance(component, Component) else component() + component._add_style_recursive(style, theme) + return route, component, compile_page(route, component, cls.STATE) @classmethod - def compile_custom_components(cls): - """Compile the custom components. - - Returns: - The path and code of the compiled custom components. - - Raises: - ValueError: If the custom components are not set. - """ - if cls.CUSTOM_COMPONENTS is None: - raise ValueError("CUSTOM_COMPONENTS should be set") - return compile_components(cls.CUSTOM_COMPONENTS) - - @classmethod - def compile_theme(cls): + def compile_theme(cls, style: ComponentStyle | None) -> tuple[str, str]: """Compile the theme. + Args: + style: The style to compile. + Returns: The path and code of the compiled theme. Raises: ValueError: If the style is not set. """ - if cls.STYLE is None: + if style is None: raise ValueError("STYLE should be set") - return compile_theme(cls.STYLE) + return compile_theme(style) diff --git a/reflex/config.py b/reflex/config.py index ba86d911d..22a04c50c 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -3,7 +3,9 @@ from __future__ import annotations import dataclasses +import enum import importlib +import inspect import os import sys import urllib.parse @@ -221,6 +223,28 @@ def interpret_path_env(value: str, field_name: str) -> Path: return path +def interpret_enum_env(value: str, field_type: GenericType, field_name: str) -> Any: + """Interpret an enum environment variable value. + + Args: + value: The environment variable value. + field_type: The field type. + field_name: The field name. + + Returns: + The interpreted value. + + Raises: + EnvironmentVarValueError: If the value is invalid. + """ + try: + return field_type(value) + except ValueError as ve: + raise EnvironmentVarValueError( + f"Invalid enum value: {value} for {field_name}" + ) from ve + + def interpret_env_var_value( value: str, field_type: GenericType, field_name: str ) -> Any: @@ -252,6 +276,8 @@ def interpret_env_var_value( return interpret_int_env(value, field_name) elif field_type is Path: return interpret_path_env(value, field_name) + elif inspect.isclass(field_type) and issubclass(field_type, enum.Enum): + return interpret_enum_env(value, field_type, field_name) else: raise ValueError( diff --git a/reflex/constants/colors.py b/reflex/constants/colors.py index ddd093f25..60942c775 100644 --- a/reflex/constants/colors.py +++ b/reflex/constants/colors.py @@ -35,7 +35,6 @@ ColorType = Literal[ "amber", "gold", "bronze", - "gray", "accent", "black", "white", diff --git a/reflex/constants/installer.py b/reflex/constants/installer.py index 766dcf5be..e1caeabed 100644 --- a/reflex/constants/installer.py +++ b/reflex/constants/installer.py @@ -120,7 +120,7 @@ class Node(SimpleNamespace): # The Node version. VERSION = "22.10.0" # The minimum required node version. - MIN_VERSION = "18.17.0" + MIN_VERSION = "18.18.0" @classproperty @classmethod @@ -173,17 +173,17 @@ class PackageJson(SimpleNamespace): PATH = "package.json" DEPENDENCIES = { - "@babel/standalone": "7.25.8", + "@babel/standalone": "7.26.0", "@emotion/react": "11.13.3", "axios": "1.7.7", "json5": "2.2.3", - "next": "14.2.15", + "next": "15.0.1", "next-sitemap": "4.2.3", "next-themes": "0.3.0", "react": "18.3.1", "react-dom": "18.3.1", "react-focus-lock": "2.13.2", - "socket.io-client": "4.8.0", + "socket.io-client": "4.8.1", "universal-cookie": "7.2.1", } DEV_DEPENDENCIES = { diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index a8566eafb..74a25c2cd 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -182,7 +182,7 @@ class ClientStateVar(Var): if value is not NoValue: # This is a hack to make it work like an EventSpec taking an arg value_var = LiteralVar.create(value) - _var_data = _var_data.merge(value_var._get_all_var_data()) + _var_data = VarData.merge(_var_data, value_var._get_all_var_data()) value_str = str(value_var) if value_str.startswith("_"): diff --git a/reflex/vars/base.py b/reflex/vars/base.py index 2007bc091..2f26e9170 100644 --- a/reflex/vars/base.py +++ b/reflex/vars/base.py @@ -151,31 +151,41 @@ class VarData: """ return dict((k, list(v)) for k, v in self.imports) - @classmethod - def merge(cls, *others: VarData | None) -> VarData | None: + def merge(*all: VarData | None) -> VarData | None: """Merge multiple var data objects. Args: - *others: The var data objects to merge. + *all: The var data objects to merge. Returns: The merged var data object. + + # noqa: DAR102 *all """ - state = "" - field_name = "" - _imports = {} - hooks = {} - for var_data in others: - if var_data is None: - continue - state = state or var_data.state - field_name = field_name or var_data.field_name - _imports = imports.merge_imports(_imports, var_data.imports) - hooks.update( - var_data.hooks - if isinstance(var_data.hooks, dict) - else {k: None for k in var_data.hooks} - ) + all_var_datas = list(filter(None, all)) + + if not all_var_datas: + return None + + if len(all_var_datas) == 1: + return all_var_datas[0] + + # Get the first non-empty field name or default to empty string. + field_name = next( + (var_data.field_name for var_data in all_var_datas if var_data.field_name), + "", + ) + + # Get the first non-empty state or default to empty string. + state = next( + (var_data.state for var_data in all_var_datas if var_data.state), "" + ) + + hooks = {hook: None for var_data in all_var_datas for hook in var_data.hooks} + + _imports = imports.merge_imports( + *(var_data.imports for var_data in all_var_datas) + ) if state or _imports or hooks or field_name: return VarData( @@ -184,6 +194,7 @@ class VarData: imports=_imports, hooks=hooks, ) + return None def __bool__(self) -> bool: diff --git a/tests/units/test_app.py b/tests/units/test_app.py index a4ecfc5f7..6bb81522f 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -237,9 +237,12 @@ def test_add_page_default_route(app: App, index_page, about_page): about_page: The about page. """ assert app.pages == {} + assert app.unevaluated_pages == {} app.add_page(index_page) + app._compile_page("index") assert app.pages.keys() == {"index"} app.add_page(about_page) + app._compile_page("about") assert app.pages.keys() == {"index", "about"} @@ -252,8 +255,9 @@ def test_add_page_set_route(app: App, index_page, windows_platform: bool): windows_platform: Whether the system is windows. """ route = "test" if windows_platform else "/test" - assert app.pages == {} + assert app.unevaluated_pages == {} app.add_page(index_page, route=route) + app._compile_page("test") assert app.pages.keys() == {"test"} @@ -267,8 +271,9 @@ def test_add_page_set_route_dynamic(index_page, windows_platform: bool): app = App(state=EmptyState) assert app.state is not None route = "/test/[dynamic]" - assert app.pages == {} + assert app.unevaluated_pages == {} app.add_page(index_page, route=route) + app._compile_page("test/[dynamic]") assert app.pages.keys() == {"test/[dynamic]"} assert "dynamic" in app.state.computed_vars assert app.state.computed_vars["dynamic"]._deps(objclass=EmptyState) == { @@ -286,9 +291,9 @@ def test_add_page_set_route_nested(app: App, index_page, windows_platform: bool) windows_platform: Whether the system is windows. """ route = "test\\nested" if windows_platform else "/test/nested" - assert app.pages == {} + assert app.unevaluated_pages == {} app.add_page(index_page, route=route) - assert app.pages.keys() == {route.strip(os.path.sep)} + assert app.unevaluated_pages.keys() == {route.strip(os.path.sep)} def test_add_page_invalid_api_route(app: App, index_page): @@ -1238,6 +1243,7 @@ def test_overlay_component( app.add_page(rx.box("Index"), route="/test") # overlay components are wrapped during compile only + app._compile_page("test") app._setup_overlay_component() page = app.pages["test"] @@ -1365,6 +1371,7 @@ def test_app_state_determination(): # Add a page with `on_load` enables state. a1.add_page(rx.box("About"), route="/about", on_load=rx.console_log("")) + a1._compile_page("about") assert a1.state is not None a2 = App() @@ -1372,6 +1379,7 @@ def test_app_state_determination(): # Referencing a state Var enables state. a2.add_page(rx.box(rx.text(GenState.value)), route="/") + a2._compile_page("index") assert a2.state is not None a3 = App() @@ -1379,6 +1387,7 @@ def test_app_state_determination(): # Referencing router enables state. a3.add_page(rx.box(rx.text(State.router.page.full_path)), route="/") + a3._compile_page("index") assert a3.state is not None a4 = App() @@ -1390,6 +1399,7 @@ def test_app_state_determination(): a4.add_page( rx.box(rx.button("Click", on_click=DynamicState.on_counter)), route="/page2" ) + a4._compile_page("page2") assert a4.state is not None @@ -1469,6 +1479,9 @@ def test_add_page_component_returning_tuple(): app.add_page(index) # type: ignore app.add_page(page2) # type: ignore + app._compile_page("index") + app._compile_page("page2") + assert isinstance((fragment_wrapper := app.pages["index"].children[0]), Fragment) assert isinstance((first_text := fragment_wrapper.children[0]), Text) assert str(first_text.children[0].contents) == '"first"' # type: ignore diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 1027042c9..0c63abc96 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -7,8 +7,13 @@ import pytest import reflex as rx import reflex.config -from reflex.config import environment -from reflex.constants import Endpoint +from reflex.config import ( + environment, + interpret_boolean_env, + interpret_enum_env, + interpret_int_env, +) +from reflex.constants import Endpoint, Env def test_requires_app_name(): @@ -208,11 +213,11 @@ def test_replace_defaults( assert getattr(c, key) == value -def reflex_dir_constant(): +def reflex_dir_constant() -> Path: return environment.REFLEX_DIR -def test_reflex_dir_env_var(monkeypatch, tmp_path): +def test_reflex_dir_env_var(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Test that the REFLEX_DIR environment variable is used to set the Reflex.DIR constant. Args: @@ -224,3 +229,16 @@ def test_reflex_dir_env_var(monkeypatch, tmp_path): mp_ctx = multiprocessing.get_context(method="spawn") with mp_ctx.Pool(processes=1) as pool: assert pool.apply(reflex_dir_constant) == tmp_path + + +def test_interpret_enum_env() -> None: + assert interpret_enum_env(Env.PROD.value, Env, "REFLEX_ENV") == Env.PROD + + +def test_interpret_int_env() -> None: + assert interpret_int_env("3001", "FRONTEND_PORT") == 3001 + + +@pytest.mark.parametrize("value, expected", [("true", True), ("false", False)]) +def test_interpret_bool_env(value: str, expected: bool) -> None: + assert interpret_boolean_env(value, "TELEMETRY_ENABLED") == expected