Copy/update assets on compile (#4765)

* Add path_ops.update_directory_tree:

Copy missing and newer files from src to dest

* add console.timing context

Log debug messages with timing for different processes.

* Update assets tree as app._compile step.

If the assets change between hot reload, then update them before reloading (in
case a CSS file was added or something).

* Add timing for other app._compile events

* Only copy assets if assets exist

* Fix docstring for update_directory_tree
This commit is contained in:
Masen Furer 2025-02-07 14:59:22 -08:00
parent 4b5c59c329
commit 8ef6fb5455
No known key found for this signature in database
GPG Key ID: B0008AD22B3B3A95
3 changed files with 94 additions and 21 deletions

View File

@ -99,7 +99,15 @@ from reflex.state import (
_substate_key, _substate_key,
code_uses_state_contexts, code_uses_state_contexts,
) )
from reflex.utils import codespaces, console, exceptions, format, prerequisites, types from reflex.utils import (
codespaces,
console,
exceptions,
format,
path_ops,
prerequisites,
types,
)
from reflex.utils.exec import is_prod_mode, is_testing_env from reflex.utils.exec import is_prod_mode, is_testing_env
from reflex.utils.imports import ImportVar from reflex.utils.imports import ImportVar
@ -974,9 +982,10 @@ class App(MiddlewareMixin, LifespanMixin):
should_compile = self._should_compile() should_compile = self._should_compile()
if not should_compile: if not should_compile:
for route in self._unevaluated_pages: with console.timing("Evaluate Pages (Backend)"):
console.debug(f"Evaluating page: {route}") for route in self._unevaluated_pages:
self._compile_page(route, save_page=should_compile) console.debug(f"Evaluating page: {route}")
self._compile_page(route, save_page=should_compile)
# Add the optional endpoints (_upload) # Add the optional endpoints (_upload)
self._add_optional_endpoints() self._add_optional_endpoints()
@ -1002,10 +1011,11 @@ class App(MiddlewareMixin, LifespanMixin):
+ adhoc_steps_without_executor, + adhoc_steps_without_executor,
) )
for route in self._unevaluated_pages: with console.timing("Evaluate Pages (Frontend)"):
console.debug(f"Evaluating page: {route}") for route in self._unevaluated_pages:
self._compile_page(route, save_page=should_compile) console.debug(f"Evaluating page: {route}")
progress.advance(task) self._compile_page(route, save_page=should_compile)
progress.advance(task)
# Add the optional endpoints (_upload) # Add the optional endpoints (_upload)
self._add_optional_endpoints() self._add_optional_endpoints()
@ -1040,13 +1050,13 @@ class App(MiddlewareMixin, LifespanMixin):
custom_components |= component._get_all_custom_components() custom_components |= component._get_all_custom_components()
# Perform auto-memoization of stateful components. # Perform auto-memoization of stateful components.
( with console.timing("Auto-memoize StatefulComponents"):
stateful_components_path, (
stateful_components_code, stateful_components_path,
page_components, stateful_components_code,
) = compiler.compile_stateful_components(self._pages.values()) page_components,
) = compiler.compile_stateful_components(self._pages.values())
progress.advance(task) progress.advance(task)
# Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State. # 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: if code_uses_state_contexts(stateful_components_code) and self._state is None:
@ -1069,6 +1079,17 @@ class App(MiddlewareMixin, LifespanMixin):
progress.advance(task) progress.advance(task)
# Copy the assets.
assets_src = Path.cwd() / constants.Dirs.APP_ASSETS
if assets_src.is_dir():
with console.timing("Copy assets"):
path_ops.update_directory_tree(
src=assets_src,
dest=(
Path.cwd() / prerequisites.get_web_dir() / constants.Dirs.PUBLIC
),
)
# Use a forking process pool, if possible. Much faster, especially for large sites. # Use a forking process pool, if possible. Much faster, especially for large sites.
# Fallback to ThreadPoolExecutor as something that will always work. # Fallback to ThreadPoolExecutor as something that will always work.
executor = None executor = None
@ -1121,9 +1142,10 @@ class App(MiddlewareMixin, LifespanMixin):
_submit_work(compiler.remove_tailwind_from_postcss) _submit_work(compiler.remove_tailwind_from_postcss)
# Wait for all compilation tasks to complete. # Wait for all compilation tasks to complete.
for future in concurrent.futures.as_completed(result_futures): with console.timing("Compile to Javascript"):
compile_results.append(future.result()) for future in concurrent.futures.as_completed(result_futures):
progress.advance(task) compile_results.append(future.result())
progress.advance(task)
app_root = self._app_root(app_wrappers=app_wrappers) app_root = self._app_root(app_wrappers=app_wrappers)
@ -1158,7 +1180,8 @@ class App(MiddlewareMixin, LifespanMixin):
progress.stop() progress.stop()
# Install frontend packages. # Install frontend packages.
self._get_frontend_packages(all_imports) with console.timing("Install Frontend Packages"):
self._get_frontend_packages(all_imports)
# Setup the next.config.js # Setup the next.config.js
transpile_packages = [ transpile_packages = [
@ -1184,8 +1207,9 @@ class App(MiddlewareMixin, LifespanMixin):
# Remove pages that are no longer in the app. # Remove pages that are no longer in the app.
p.unlink() p.unlink()
for output_path, code in compile_results: with console.timing("Write to Disk"):
compiler_utils.write_page(output_path, code) for output_path, code in compile_results:
compiler_utils.write_page(output_path, code)
@contextlib.asynccontextmanager @contextlib.asynccontextmanager
async def modify_state(self, token: str) -> AsyncIterator[BaseState]: async def modify_state(self, token: str) -> AsyncIterator[BaseState]:

View File

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import inspect import inspect
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from types import FrameType from types import FrameType
@ -317,3 +319,20 @@ def status(*args, **kwargs):
A new status. A new status.
""" """
return _console.status(*args, **kwargs) return _console.status(*args, **kwargs)
@contextlib.contextmanager
def timing(msg: str):
"""Create a context manager to time a block of code.
Args:
msg: The message to display.
Yields:
None.
"""
start = time.time()
try:
yield
finally:
debug(f"[white]\\[timing] {msg}: {time.time() - start:.2f}s[/white]")

View File

@ -245,3 +245,33 @@ def find_replace(directory: str | Path, find: str, replace: str):
text = filepath.read_text(encoding="utf-8") text = filepath.read_text(encoding="utf-8")
text = re.sub(find, replace, text) text = re.sub(find, replace, text)
filepath.write_text(text, encoding="utf-8") filepath.write_text(text, encoding="utf-8")
def update_directory_tree(src: Path, dest: Path):
"""Recursively copies a directory tree from src to dest.
Only copies files if the destination file is missing or modified earlier than the source file.
Args:
src: Source directory
dest: Destination directory
Raises:
ValueError: If the source is not a directory
"""
if not src.is_dir():
raise ValueError(f"Source {src} is not a directory")
# Ensure the destination directory exists
dest.mkdir(parents=True, exist_ok=True)
for item in src.iterdir():
dest_item = dest / item.name
if item.is_dir():
# Recursively copy subdirectories
update_directory_tree(item, dest_item)
elif item.is_file() and (
not dest_item.exists() or item.stat().st_mtime > dest_item.stat().st_mtime
):
# Copy file if it doesn't exist in the destination or is older than the source
shutil.copy2(item, dest_item)