diff --git a/reflex/app.py b/reflex/app.py index abf0b5d411..961388fde6 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -6,6 +6,7 @@ import concurrent.futures import contextlib import copy +import dataclasses import functools import inspect import io @@ -18,6 +19,7 @@ from datetime import datetime from pathlib import Path from typing import ( + TYPE_CHECKING, Any, AsyncIterator, Callable, @@ -47,7 +49,10 @@ 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.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 @@ def _add_default_endpoints(self): 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 @@ def add_page( 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 @@ def add_page( # 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 @@ def add_page( 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) - - # 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) + if on_load: + self.load_events[route] = ( + on_load if isinstance(on_load, list) else [on_load] + ) - meta_args = { - "title": ( - title - if title is not None - else format.make_default_page_title(get_config().app_name, route) - ), - "image": image, - "meta": meta, - } + self.unevaluated_pages[route] = UnevaluatedPage( + component=component, + route=route, + title=title, + description=description, + image=image, + on_load=on_load, + meta=meta, + ) - if description is not None: - meta_args["description"] = description + def _compile_page(self, route: str): + """Compile a page. - # Add meta information to the component. - compiler_utils.add_meta( - component, - **meta_args, + Args: + route: The route of the page to compile. + """ + component, enable_state = compiler.compile_unevaluated_page( + route, self.unevaluated_pages[route], self.state ) + 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,18 @@ def _compile(self, export: bool = False): """ 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() + for route in self.unevaluated_pages: + self._compile_page(route) + # Add the optional endpoints (_upload) self._add_optional_endpoints() @@ -857,7 +868,7 @@ def get_compilation_time() -> str: progress.start() task = progress.add_task( f"[{get_compilation_time()}] Compiling:", - total=len(self.pages) + total=len(self.unevaluated_pages) + fixed_pages_within_executor + adhoc_steps_without_executor, ) @@ -886,38 +897,8 @@ def get_compilation_time() -> str: 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) - - # Add component._get_all_imports() to all_imports. - all_imports.update(component._get_all_imports()) - - # 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() - - progress.advance(task) - - # Perform auto-memoization of stateful components. - ( - stateful_components_path, - stateful_components_code, - page_components, - ) = compiler.compile_stateful_components(self.pages.values()) - progress.advance(task) - # Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State. - if code_uses_state_contexts(stateful_components_code) and self.state is None: - raise ReflexRuntimeError( - "To access rx.State in frontend components, at least one " - "subclass of rx.State must be defined in the app." - ) - compile_results.append((stateful_components_path, stateful_components_code)) - # Compile the root document before fork. compile_results.append( compiler.compile_document_root( @@ -927,31 +908,12 @@ def get_compilation_time() -> str: ) ) - # 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 +931,55 @@ def get_compilation_time() -> str: max_workers=environment.REFLEX_COMPILE_THREADS ) + for route, component in self.pages.items(): + component._add_style_recursive(self.style, self.theme) + + ExecutorSafeFunctions.COMPONENTS[route] = component + + for route, page in self.unevaluated_pages.items(): + if route in self.pages: + continue + + ExecutorSafeFunctions.UNCOMPILED_PAGES[route] = page + + ExecutorSafeFunctions.STATE = self.state + + pages_results = [] + with executor: result_futures = [] - custom_components_future = None - - def _mark_complete(_=None): - progress.advance(task) + pages_futures = [] 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. - for route in self.pages: - _submit_work(ExecutorSafeFunctions.compile_page, route) - - # Compile the app wrapper. - _submit_work(ExecutorSafeFunctions.compile_app) + for route in self.unevaluated_pages: + if route in self.pages: + continue + + f = executor.submit( + ExecutorSafeFunctions.compile_unevaluated_page, + route, + self.style, + self.theme, + ) + pages_futures.append(f) - # Compile the custom components. - custom_components_future = executor.submit( - ExecutorSafeFunctions.compile_custom_components, - ) - custom_components_future.add_done_callback(_mark_complete) + # Compile the pre-compiled pages. + for route in self.pages: + _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 +993,70 @@ def _submit_work(fn, *args, **kwargs): # Wait for all compilation tasks to complete. for future in concurrent.futures.as_completed(result_futures): compile_results.append(future.result()) + progress.advance(task) + + for future in concurrent.futures.as_completed(pages_futures): + pages_results.append(future.result()) + progress.advance(task) + + for route, component, compiled_page in pages_results: + self._check_routes_conflict(route) + self.pages[route] = component + compile_results.append(compiled_page) - # 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) + for _, component in self.pages.items(): + # Add component._get_all_imports() to all_imports. + all_imports.update(component._get_all_imports()) + + # 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() + + # Perform auto-memoization of stateful components. + ( + stateful_components_path, + stateful_components_code, + page_components, + ) = compiler.compile_stateful_components(self.pages.values()) + + progress.advance(task) + + # Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State. + if code_uses_state_contexts(stateful_components_code) and self.state is None: + raise ReflexRuntimeError( + "To access rx.State in frontend components, at least one " + "subclass of rx.State must be defined in the app." + ) + compile_results.append((stateful_components_path, stateful_components_code)) + + 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 909299635c..816a7e1d4a 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -4,10 +4,11 @@ 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, @@ -127,7 +128,7 @@ def _compile_contexts(state: Optional[Type[BaseState]], theme: Component | None) def _compile_page( component: Component, - state: Type[BaseState], + 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: Component, state: Type[BaseState] | None ) -> tuple[str, str]: """Compile a single page. @@ -534,6 +535,73 @@ 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 +) -> 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. + + 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) + + 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 +627,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, Component] = {} + 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 +641,45 @@ def compile_page(cls, route: str): 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. - - Returns: - The path and code of the compiled app. + def compile_unevaluated_page( + cls, + route: str, + style: ComponentStyle, + theme: Component | None, + ) -> tuple[str, Component, tuple[str, str]]: + """Compile an unevaluated page. - Raises: - ValueError: If the app root is not set. - """ - 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) - - @classmethod - def compile_custom_components(cls): - """Compile the custom components. + 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 custom components. - - Raises: - ValueError: If the custom components are not set. + The route, compiled component, and compiled page. """ - if cls.CUSTOM_COMPONENTS is None: - raise ValueError("CUSTOM_COMPONENTS should be set") - return compile_components(cls.CUSTOM_COMPONENTS) + 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_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/tests/units/test_app.py b/tests/units/test_app.py index a4ecfc5f7c..6bb81522f0 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 page2(): 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