浏览代码

Incrementally Add New Packages (#1607)

Alek Petuskey 1 年之前
父节点
当前提交
fed75ea7f8

+ 2 - 18
reflex/.templates/web/package.json

@@ -7,7 +7,6 @@
     "prod": "next start"
   },
   "dependencies": {
-    "@chakra-ui/icons": "^2.0.19",
     "@chakra-ui/react": "^2.6.0",
     "@chakra-ui/system": "^2.5.6",
     "@emotion/react": "^11.10.6",
@@ -15,32 +14,17 @@
     "axios": "^1.4.0",
     "chakra-react-select": "^4.6.0",
     "focus-visible": "^5.2.0",
-    "framer-motion": "^10.12.4",
-    "gridjs": "^6.0.6",
-    "gridjs-react": "^6.0.1",
     "json5": "^2.2.3",
     "next": "^13.3.1",
     "next-sitemap": "^4.1.8",
-    "plotly.js": "^2.22.0",
     "react": "^18.2.0",
-    "react-debounce-input": "^3.3.0",
     "react-dom": "^18.2.0",
-    "react-dropzone": "^14.2.3",
-    "react-markdown": "^8.0.7",
-    "react-player": "^2.12.0",
-    "react-plotly.js": "^2.6.0",
-    "react-syntax-highlighter": "^15.5.0",
-    "rehype-katex": "^6.0.3",
-    "rehype-raw": "^6.1.1",
-    "remark-gfm": "^3.0.1",
-    "remark-math": "^5.1.1",
     "socket.io-client": "^4.6.1",
-    "universal-cookie": "^4.0.4",
-    "victory": "^36.6.8"
+    "universal-cookie": "^4.0.4"
   },
   "devDependencies": {
     "autoprefixer": "^10.4.14",
     "postcss": "^8.4.24",
     "tailwindcss": "^3.3.2"
   }
-}
+}

+ 38 - 4
reflex/app.py

@@ -47,7 +47,7 @@ from reflex.route import (
     verify_route_validity,
 )
 from reflex.state import DefaultState, State, StateManager, StateUpdate
-from reflex.utils import console, format, types
+from reflex.utils import console, format, prerequisites, types
 
 # Define custom types.
 ComponentCallable = Callable[[], Component]
@@ -483,6 +483,27 @@ class App(Base):
 
             admin.mount_to(self.api)
 
+    def get_frontend_packages(self, imports: Dict[str, str]):
+        """Gets the frontend packages to be installed and filters out the unnecessary ones.
+
+        Args:
+            imports: A dictionary containing the imports used in the current page.
+
+        Example:
+            >>> get_frontend_packages({"react": "16.14.0", "react-dom": "16.14.0"})
+        """
+        page_imports = [
+            i
+            for i in imports
+            if i not in compiler.DEFAULT_IMPORTS.keys()
+            and i != "focus-visible/dist/focus-visible"
+            and "next" not in i
+            and not i.startswith("/")
+            and i != ""
+        ]
+        page_imports.extend(get_config().frontend_packages)
+        prerequisites.install_frontend_packages(page_imports)
+
     def compile(self):
         """Compile the app and output it to the pages folder."""
         if os.environ.get(constants.SKIP_COMPILE_ENV_VAR) == "yes":
@@ -493,12 +514,13 @@ class App(Base):
             MofNCompleteColumn(),
             TimeElapsedColumn(),
         )
-        task = progress.add_task("Compiling: ", total=len(self.pages))
 
-        # TODO: include all work done in progress indicator, not just self.pages
         for render, kwargs in DECORATED_PAGES:
             self.add_page(render, **kwargs)
 
+        task = progress.add_task("Compiling: ", total=len(self.pages))
+        # TODO: include all work done in progress indicator, not just self.pages
+
         # Get the env mode.
         config = get_config()
 
@@ -509,6 +531,7 @@ class App(Base):
         custom_components = set()
         # TODO Anecdotally, processes=2 works 10% faster (cpu_count=12)
         thread_pool = ThreadPool()
+        all_imports = {}
         with progress:
             for route, component in self.pages.items():
                 # TODO: this progress does not reflect actual threaded task completion
@@ -524,8 +547,12 @@ class App(Base):
                         ),
                     )
                 )
+                # add component.get_imports() to all_imports
+                all_imports.update(component.get_imports())
+
                 # Add the custom components from the page to the set.
                 custom_components |= component.get_custom_components()
+
         thread_pool.close()
         thread_pool.join()
 
@@ -537,7 +564,11 @@ class App(Base):
         # Compile the custom components.
         compile_results.append(compiler.compile_components(custom_components))
 
-        # Compile the root document with base styles and fonts.
+        # Iterate through all the custom components and add their imports to the all_imports
+        for component in custom_components:
+            all_imports.update(component.get_imports())
+
+        # Compile the root document with base styles and fonts
         compile_results.append(compiler.compile_document_root(self.stylesheets))
 
         # Compile the theme.
@@ -556,6 +587,9 @@ class App(Base):
         # Empty the .web pages directory
         compiler.purge_web_pages_dir()
 
+        # install frontend packages
+        self.get_frontend_packages(all_imports)
+
         # Write the pages at the end to trigger the NextJS hot reload only once.
         thread_pool = ThreadPool()
         for output_path, code in compile_results:

+ 15 - 2
reflex/compiler/utils.py

@@ -25,7 +25,7 @@ from reflex.components.component import Component, ComponentStyle, CustomCompone
 from reflex.state import Cookie, LocalStorage, State
 from reflex.style import Style
 from reflex.utils import format, imports, path_ops
-from reflex.vars import ImportVar
+from reflex.vars import ImportVar, NoRenderImportVar
 
 # To re-export this function.
 merge_imports = imports.merge_imports
@@ -42,6 +42,9 @@ def compile_import_statement(fields: Set[ImportVar]) -> Tuple[str, Set[str]]:
         default: default library. When install "import def from library".
         rest: rest of libraries. When install "import {rest1, rest2} from library"
     """
+    # ignore the NoRenderImportVar fields during compilation
+    fields = {field for field in fields if not isinstance(field, NoRenderImportVar)}
+
     # Check for default imports.
     defaults = {field for field in fields if field.is_default}
     assert len(defaults) < 2
@@ -72,7 +75,8 @@ def validate_imports(imports: imports.ImportDict):
                 raise ValueError(
                     f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}"
                 )
-            used_tags[import_name] = lib
+            if import_name is not None:
+                used_tags[import_name] = lib
 
 
 def compile_imports(imports: imports.ImportDict) -> List[dict]:
@@ -87,6 +91,10 @@ def compile_imports(imports: imports.ImportDict) -> List[dict]:
     import_dicts = []
     for lib, fields in imports.items():
         default, rest = compile_import_statement(fields)
+        # prevent all NoRenderImportVar from being rendered on the page
+        if any({f for f in fields if isinstance(f, NoRenderImportVar)}):  # type: ignore
+            continue
+
         if not lib:
             assert not default, "No default field allowed for empty library."
             assert rest is not None and len(rest) > 0, "No fields to import."
@@ -94,6 +102,11 @@ def compile_imports(imports: imports.ImportDict) -> List[dict]:
                 import_dicts.append(get_import_dict(module))
             continue
 
+        # remove the version before rendering the package imports
+        lib, at, version = lib.rpartition("@")
+        if not lib:
+            lib = at + version
+
         import_dicts.append(get_import_dict(lib, default, rest))
     return import_dicts
 

+ 26 - 5
reflex/components/component.py

@@ -22,7 +22,7 @@ from reflex.event import (
 )
 from reflex.style import Style
 from reflex.utils import format, imports, types
-from reflex.vars import BaseVar, ImportVar, Var
+from reflex.vars import BaseVar, ImportVar, NoRenderImportVar, Var
 
 
 class Component(Base, ABC):
@@ -40,6 +40,9 @@ class Component(Base, ABC):
     # The library that the component is based on.
     library: Optional[str] = None
 
+    # List here the non-react dependency needed by `library`
+    lib_dependencies: List[str] = []
+
     # The tag to use when rendering the component.
     tag: Optional[str] = None
 
@@ -515,9 +518,12 @@ class Component(Base, ABC):
         return code
 
     def _get_imports(self) -> imports.ImportDict:
+        imports = {}
         if self.library is not None and self.tag is not None:
-            return {self.library: {self.import_var}}
-        return {}
+            imports[self.library] = {self.import_var}
+        for dep in self.lib_dependencies:
+            imports[dep] = {NoRenderImportVar()}  # type: ignore
+        return imports
 
     def get_imports(self) -> imports.ImportDict:
         """Get all the libraries and fields that are used by the component.
@@ -833,11 +839,26 @@ class NoSSRComponent(Component):
     """A dynamic component that is not rendered on the server."""
 
     def _get_imports(self):
-        return {"next/dynamic": {ImportVar(tag="dynamic", is_default=True)}}
+        imports = {"next/dynamic": {ImportVar(tag="dynamic", is_default=True)}}
+
+        for dep in [self.library, *self.lib_dependencies]:
+            imports[dep] = {NoRenderImportVar()}  # type: ignore
+
+        return imports
 
     def _get_custom_code(self) -> str:
         opts_fragment = ", { ssr: false });"
-        library_import = f"const {self.tag} = dynamic(() => import('{self.library}')"
+
+        # extract the correct import name from library name
+        if self.library is None:
+            raise ValueError("Undefined library for NoSSRComponent")
+
+        import_name_parts = [p for p in self.library.rpartition("@") if p != ""]
+        import_name = (
+            import_name_parts[0] if import_name_parts[0] != "@" else self.library
+        )
+
+        library_import = f"const {self.tag} = dynamic(() => import('{import_name}')"
         mod_import = (
             # https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#with-named-exports
             f".then((mod) => mod.{self.tag})"

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

@@ -19,7 +19,7 @@ PRISM_STYLES_PATH = "/styles/code/prism"
 class CodeBlock(Component):
     """A code block."""
 
-    library = "react-syntax-highlighter"
+    library = "react-syntax-highlighter@^15.5.0"
 
     tag = "Prism"
 

+ 3 - 1
reflex/components/datadisplay/datatable.py

@@ -11,7 +11,9 @@ from reflex.vars import BaseVar, ComputedVar, ImportVar, Var
 class Gridjs(Component):
     """A component that wraps a nivo bar component."""
 
-    library = "gridjs-react"
+    library = "gridjs-react@^6.0.1"
+
+    lib_dependencies: List[str] = ["gridjs@^6.0.6"]
 
 
 class DataTable(Gridjs):

+ 1 - 1
reflex/components/forms/debounce.py

@@ -16,7 +16,7 @@ class DebounceInput(Component):
     is experiencing high latency.
     """
 
-    library = "react-debounce-input"
+    library = "react-debounce-input@^3.3.0"
     tag = "DebounceInput"
 
     # Minimum input characters before triggering the on_change event

+ 1 - 1
reflex/components/forms/upload.py

@@ -21,7 +21,7 @@ clear_selected_files = BaseVar(name="_e => setFiles((files) => [])", type_=Event
 class Upload(Component):
     """A file upload component."""
 
-    library = "react-dropzone"
+    library = "react-dropzone@^14.2.3"
 
     tag = "ReactDropzone"
 

+ 4 - 2
reflex/components/graphing/plotly.py

@@ -1,6 +1,6 @@
 """Component for displaying a plotly graph."""
 
-from typing import Dict
+from typing import Dict, List
 
 from plotly.graph_objects import Figure
 
@@ -12,7 +12,9 @@ from reflex.vars import Var
 class PlotlyLib(NoSSRComponent):
     """A component that wraps a plotly lib."""
 
-    library = "react-plotly.js"
+    library = "react-plotly.js@^2.6.0"
+
+    lib_dependencies: List[str] = ["plotly.js@^2.22.0"]
 
 
 class Plotly(PlotlyLib):

+ 1 - 1
reflex/components/graphing/victory.py

@@ -334,7 +334,7 @@ def data(graph: str, x: List, y: Optional[List] = None, **kwargs) -> List:
 class Victory(Component):
     """A component that wraps a victory lib."""
 
-    library = "victory"
+    library = "victory@^36.6.8"
 
     # The data to display.
     data: Var[List[Dict]]

+ 1 - 1
reflex/components/libs/react_player.py

@@ -11,7 +11,7 @@ class ReactPlayerComponent(NoSSRComponent):
     reference: https://github.com/cookpete/react-player.
     """
 
-    library = "react-player/lazy"
+    library = "react-player@^2.12.0"
 
     tag = "ReactPlayer"
 

+ 1 - 1
reflex/components/media/icon.py

@@ -7,7 +7,7 @@ from reflex.utils import format
 class ChakraIconComponent(Component):
     """A component that wraps a Chakra icon component."""
 
-    library = "@chakra-ui/icons"
+    library = "@chakra-ui/icons@^2.0.19"
 
 
 class Icon(ChakraIconComponent):

+ 8 - 1
reflex/components/typography/markdown.py

@@ -32,7 +32,14 @@ components_by_tag: Dict[str, Callable] = {
 class Markdown(Component):
     """A markdown component."""
 
-    library = "react-markdown"
+    library = "react-markdown@^8.0.7"
+
+    lib_dependencies: List[str] = [
+        "rehype-katex@^6.0.3",
+        "remark-math@^5.1.1",
+        "rehype-raw@^6.1.1",
+        "remark-gfm@^3.0.1",
+    ]
 
     tag = "ReactMarkdown"
 

+ 0 - 0
reflex/py.typed


+ 3 - 0
reflex/reflex.py

@@ -127,6 +127,9 @@ def run(
         frontend = True
         backend = True
 
+    if not frontend and backend:
+        _skip_compile()
+
     # Check that the app is initialized.
     prerequisites.check_initialized(frontend=frontend)
 

+ 0 - 3
reflex/utils/build.py

@@ -223,9 +223,6 @@ def setup_frontend(
         root: The root path of the project.
         disable_telemetry: Whether to disable the Next telemetry.
     """
-    # Install frontend packages.
-    prerequisites.install_frontend_packages()
-
     # Copy asset files to public folder.
     path_ops.cp(
         src=str(root / constants.APP_ASSETS_DIR),

+ 69 - 8
reflex/utils/exec.py

@@ -2,11 +2,14 @@
 
 from __future__ import annotations
 
+import hashlib
+import json
 import os
 import platform
 import sys
 from pathlib import Path
 
+import psutil
 import uvicorn
 
 from reflex import constants
@@ -25,20 +28,78 @@ def start_watching_assets_folder(root):
     asset_watch.start()
 
 
+def detect_package_change(json_file_path: str) -> str:
+    """Calculates the SHA-256 hash of a JSON file and returns it as a hexadecimal string.
+
+    Args:
+        json_file_path (str): The path to the JSON file to be hashed.
+
+    Returns:
+        str: The SHA-256 hash of the JSON file as a hexadecimal string.
+
+    Example:
+        >>> detect_package_change("package.json")
+        'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2'
+    """
+    with open(json_file_path, "r") as file:
+        json_data = json.load(file)
+
+    # Calculate the hash
+    json_string = json.dumps(json_data, sort_keys=True)
+    hash_object = hashlib.sha256(json_string.encode())
+    return hash_object.hexdigest()
+
+
+def kill(proc_pid: int):
+    """Kills a process and all its child processes.
+
+    Args:
+        proc_pid (int): The process ID of the process to be killed.
+
+    Example:
+        >>> kill(1234)
+    """
+    process = psutil.Process(proc_pid)
+    for proc in process.children(recursive=True):
+        proc.kill()
+    process.kill()
+
+
 def run_process_and_launch_url(run_command: list[str]):
     """Run the process and launch the URL.
 
     Args:
         run_command: The command to run.
     """
-    process = processes.new_process(
-        run_command, cwd=constants.WEB_DIR, shell=constants.IS_WINDOWS
-    )
-
-    for line in processes.stream_logs("Starting frontend", process):
-        if "ready started server on" in line:
-            url = line.split("url: ")[-1].strip()
-            console.print(f"App running at: [bold green]{url}")
+    json_file_path = os.path.join(constants.WEB_DIR, "package.json")
+    last_hash = detect_package_change(json_file_path)
+    process = None
+    first_run = True
+
+    while True:
+        if process is None:
+            process = processes.new_process(
+                run_command, cwd=constants.WEB_DIR, shell=constants.IS_WINDOWS
+            )
+        if process.stdout:
+            for line in processes.stream_logs("Starting frontend", process):
+                if "ready started server on" in line:
+                    if first_run:
+                        url = line.split("url: ")[-1].strip()
+                        console.print(f"App running at: [bold green]{url}")
+                        first_run = False
+                    else:
+                        console.print(f"New packages detected updating app...")
+                else:
+                    console.debug(line)
+                    new_hash = detect_package_change(json_file_path)
+                    if new_hash != last_hash:
+                        last_hash = new_hash
+                        kill(process.pid)
+                        process = None
+                        break  # for line in process.stdout
+        if process is not None:
+            break  # while True
 
 
 def run_frontend(root: Path, port: str):

+ 13 - 6
reflex/utils/prerequisites.py

@@ -14,7 +14,7 @@ import zipfile
 from fileinput import FileInput
 from pathlib import Path
 from types import ModuleType
-from typing import Optional
+from typing import List, Optional
 
 import httpx
 import typer
@@ -376,25 +376,32 @@ def install_bun():
     )
 
 
-def install_frontend_packages():
-    """Installs the base and custom frontend packages."""
+def install_frontend_packages(packages: List[str]):
+    """Installs the base and custom frontend packages.
+
+    Args:
+        packages (List[str]): A list of package names to be installed.
+
+    Example:
+        >>> install_frontend_packages(["react", "react-dom"])
+    """
     # Install the base packages.
     process = processes.new_process(
         [get_install_package_manager(), "install", "--loglevel", "silly"],
         cwd=constants.WEB_DIR,
         shell=constants.IS_WINDOWS,
     )
+
     processes.show_status("Installing base frontend packages", process)
 
-    # Install the app packages.
-    packages = get_config().frontend_packages
+    # Install the custom packages, if any.
     if len(packages) > 0:
         process = processes.new_process(
             [get_install_package_manager(), "add", *packages],
             cwd=constants.WEB_DIR,
             shell=constants.IS_WINDOWS,
         )
-        processes.show_status("Installing custom frontend packages", process)
+        processes.show_status("Installing frontend packages for components", process)
 
 
 def check_initialized(frontend: bool = True):

+ 6 - 0
reflex/vars.py

@@ -1372,6 +1372,12 @@ class ImportVar(Base):
         return hash((self.tag, self.is_default, self.alias))
 
 
+class NoRenderImportVar(ImportVar):
+    """A import that doesn't need to be rendered."""
+
+    ...
+
+
 def get_local_storage(key: Optional[Union[Var, str]] = None) -> BaseVar:
     """Provide a base var as payload to get local storage item(s).