diff --git a/reflex/app.py b/reflex/app.py index 50000e507..c52f0ef8a 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -729,9 +729,12 @@ class App(Base): for render, kwargs in DECORATED_PAGES: self.add_page(render, **kwargs) - def compile_(self): + def compile_(self, export: bool = False): """Compile the app and output it to the pages folder. + Args: + export: Whether to compile the app for export. + Raises: RuntimeError: When any page uses state, but no rx.State subclass is defined. """ @@ -937,6 +940,17 @@ class App(Base): # Install frontend packages. self.get_frontend_packages(all_imports) + # Setup the next.config.js + transpile_packages = [ + package + for package, import_vars in all_imports.items() + if any(import_var.transpile for import_var in import_vars) + ] + prerequisites.update_next_config( + export=export, + transpile_packages=transpile_packages, + ) + for output_path, code in compile_results: compiler_utils.write_page(output_path, code) diff --git a/reflex/components/component.py b/reflex/components/component.py index 057a55361..a940f4a86 100644 --- a/reflex/components/component.py +++ b/reflex/components/component.py @@ -64,6 +64,9 @@ class BaseComponent(Base, ABC): # List here the non-react dependency needed by `library` lib_dependencies: List[str] = [] + # List here the dependencies that need to be transpiled by Next.js + transpile_packages: List[str] = [] + # The tag to use when rendering the component. tag: Optional[str] = None @@ -987,6 +990,20 @@ class Component(BaseComponent, ABC): if getattr(self, prop) is not None ] + def _should_transpile(self, dep: str | None) -> bool: + """Check if a dependency should be transpiled. + + Args: + dep: The dependency to check. + + Returns: + True if the dependency should be transpiled. + """ + return ( + dep in self.transpile_packages + or format.format_library_name(dep or "") in self.transpile_packages + ) + def _get_dependencies_imports(self) -> imports.ImportDict: """Get the imports from lib_dependencies for installing. @@ -994,7 +1011,14 @@ class Component(BaseComponent, ABC): The dependencies imports of the component. """ return { - dep: [ImportVar(tag=None, render=False)] for dep in self.lib_dependencies + dep: [ + ImportVar( + tag=None, + render=False, + transpile=self._should_transpile(dep), + ) + ] + for dep in self.lib_dependencies } def _get_hooks_imports(self) -> imports.ImportDict: @@ -1250,7 +1274,12 @@ class Component(BaseComponent, ABC): # If the tag is dot-qualified, only import the left-most name. tag = self.tag.partition(".")[0] if self.tag else None alias = self.alias.partition(".")[0] if self.alias else None - return ImportVar(tag=tag, is_default=self.is_default, alias=alias) + return ImportVar( + tag=tag, + is_default=self.is_default, + alias=alias, + transpile=self._should_transpile(self.library), + ) @staticmethod def _get_app_wrap_components() -> dict[tuple[int, str], Component]: @@ -1514,7 +1543,13 @@ class NoSSRComponent(Component): # Do NOT import the main library/tag statically. if self.library is not None: - _imports[self.library] = [imports.ImportVar(tag=None, render=False)] + _imports[self.library] = [ + imports.ImportVar( + tag=None, + render=False, + transpile=self._should_transpile(self.library), + ), + ] return imports.merge_imports( dynamic_import, @@ -1529,10 +1564,7 @@ class NoSSRComponent(Component): 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 - ) + import_name = format.format_library_name(self.library) library_import = f"const {self.alias if self.alias else self.tag} = dynamic(() => import('{import_name}')" mod_import = ( diff --git a/reflex/reflex.py b/reflex/reflex.py index 754d96af9..d11dc6ab3 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -178,7 +178,6 @@ def _run( prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) if frontend: - prerequisites.update_next_config() # Get the app module. prerequisites.get_compiled_app() diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 6deb77174..3116f4859 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -50,10 +50,8 @@ def export( console.rule("[bold]Compiling production app and preparing for export.") if frontend: - # Update some parameters for export - prerequisites.update_next_config(export=True) # Ensure module can be imported and app.compile() is called. - prerequisites.get_compiled_app() + prerequisites.get_compiled_app(export=True) # Set up .web directory and install frontend dependencies. build.setup_frontend(Path.cwd()) diff --git a/reflex/utils/imports.py b/reflex/utils/imports.py index 66ac62213..8d678b4eb 100644 --- a/reflex/utils/imports.py +++ b/reflex/utils/imports.py @@ -54,6 +54,10 @@ class ImportVar(Base): # whether this import should be rendered or not render: Optional[bool] = True + # whether this import package should be added to transpilePackages in next.config.js + # https://nextjs.org/docs/app/api-reference/next-config-js/transpilePackages + transpile: Optional[bool] = False + @property def name(self) -> str: """The name of the import. @@ -72,7 +76,16 @@ class ImportVar(Base): Returns: The hash of the var. """ - return hash((self.tag, self.is_default, self.alias, self.install, self.render)) + return hash( + ( + self.tag, + self.is_default, + self.alias, + self.install, + self.render, + self.transpile, + ) + ) ImportDict = Dict[str, List[ImportVar]] diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 399724148..21c25c031 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -19,7 +19,7 @@ from datetime import datetime from fileinput import FileInput from pathlib import Path from types import ModuleType -from typing import Callable, Optional +from typing import Callable, List, Optional import httpx import pkg_resources @@ -35,6 +35,7 @@ from reflex.base import Base from reflex.compiler import templates from reflex.config import Config, get_config from reflex.utils import console, path_ops, processes +from reflex.utils.format import format_library_name CURRENTLY_INSTALLING_NODE = False @@ -227,11 +228,12 @@ def get_app(reload: bool = False) -> ModuleType: return app -def get_compiled_app(reload: bool = False) -> ModuleType: +def get_compiled_app(reload: bool = False, export: bool = False) -> ModuleType: """Get the app module based on the default config after first compiling it. Args: reload: Re-import the app module from disk + export: Compile the app for export Returns: The compiled app based on the default config. @@ -241,7 +243,7 @@ def get_compiled_app(reload: bool = False) -> ModuleType: # For py3.8 and 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). app._apply_decorated_pages() - app.compile_() + app.compile_(export=export) return app_module @@ -562,28 +564,37 @@ def init_reflex_json(project_hash: int | None): path_ops.update_json_file(constants.Reflex.JSON, reflex_json) -def update_next_config(export=False): +def update_next_config(export=False, transpile_packages: Optional[List[str]] = None): """Update Next.js config from Reflex config. Args: export: if the method run during reflex export. + transpile_packages: list of packages to transpile via next.config.js. """ next_config_file = os.path.join(constants.Dirs.WEB, constants.Next.CONFIG_FILE) - next_config = _update_next_config(get_config(), export=export) + next_config = _update_next_config( + get_config(), export=export, transpile_packages=transpile_packages + ) with open(next_config_file, "w") as file: file.write(next_config) file.write("\n") -def _update_next_config(config, export=False): +def _update_next_config( + config: Config, export: bool = False, transpile_packages: Optional[List[str]] = None +): next_config = { "basePath": config.frontend_path or "", "compress": config.next_compression, "reactStrictMode": True, "trailingSlash": True, } + if transpile_packages: + next_config["transpilePackages"] = list( + set((format_library_name(p) for p in transpile_packages)) + ) if export: next_config["output"] = "export" next_config["distDir"] = constants.Dirs.STATIC diff --git a/tests/test_app.py b/tests/test_app.py index 9cd0cfb50..1e1df01bf 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,7 +1,9 @@ from __future__ import annotations import io +import json import os.path +import re import unittest.mock import uuid from pathlib import Path @@ -1444,3 +1446,55 @@ def test_add_page_component_returning_tuple(): ) assert isinstance((third_text := page2_fragment_wrapper.children[0]), Text) assert str(third_text.children[0].contents) == "{`third`}" # type: ignore + + +@pytest.mark.parametrize("export", (True, False)) +def test_app_with_transpile_packages(compilable_app, export): + class C1(rx.Component): + library = "foo@1.2.3" + tag = "Foo" + transpile_packages: List[str] = ["foo"] + + class C2(rx.Component): + library = "bar@4.5.6" + tag = "Bar" + transpile_packages: List[str] = ["bar@4.5.6"] + + class C3(rx.NoSSRComponent): + library = "baz@7.8.10" + tag = "Baz" + transpile_packages: List[str] = ["baz@7.8.9"] + + class C4(rx.NoSSRComponent): + library = "quuc@2.3.4" + tag = "Quuc" + transpile_packages: List[str] = ["quuc"] + + class C5(rx.Component): + library = "quuc" + tag = "Quuc" + + app, web_dir = compilable_app + page = Fragment.create( + C1.create(), C2.create(), C3.create(), C4.create(), C5.create() + ) + app.add_page(page, route="/") + app.compile_(export=export) + + next_config = (web_dir / "next.config.js").read_text() + transpile_packages_match = re.search(r"transpilePackages: (\[.*?\])", next_config) + transpile_packages_json = transpile_packages_match.group(1) # type: ignore + transpile_packages = sorted(json.loads(transpile_packages_json)) + + assert transpile_packages == [ + "bar", + "foo", + "quuc", + ] + + if export: + assert 'output: "export"' in next_config + assert f'distDir: "{constants.Dirs.STATIC}"' in next_config + else: + assert 'output: "export"' not in next_config + assert f'distDir: "{constants.Dirs.STATIC}"' not in next_config diff --git a/tests/test_prerequisites.py b/tests/test_prerequisites.py index 711826cbc..28608c48c 100644 --- a/tests/test_prerequisites.py +++ b/tests/test_prerequisites.py @@ -1,3 +1,5 @@ +import json +import re import tempfile from unittest.mock import Mock, mock_open @@ -61,6 +63,30 @@ def test_update_next_config(config, export, expected_output): assert output == expected_output +@pytest.mark.parametrize( + ("transpile_packages", "expected_transpile_packages"), + ( + ( + ["foo", "@bar/baz"], + ["@bar/baz", "foo"], + ), + ( + ["foo", "@bar/baz", "foo", "@bar/baz@3.2.1"], + ["@bar/baz", "foo"], + ), + ), +) +def test_transpile_packages(transpile_packages, expected_transpile_packages): + output = _update_next_config( + Config(app_name="test"), + transpile_packages=transpile_packages, + ) + transpile_packages_match = re.search(r"transpilePackages: (\[.*?\])", output) + transpile_packages_json = transpile_packages_match.group(1) # type: ignore + actual_transpile_packages = sorted(json.loads(transpile_packages_json)) + assert actual_transpile_packages == expected_transpile_packages + + def test_initialize_requirements_txt_no_op(mocker): # File exists, reflex is included, do nothing mocker.patch("pathlib.Path.exists", return_value=True) diff --git a/tests/test_var.py b/tests/test_var.py index 61286dc83..68271d2ba 100644 --- a/tests/test_var.py +++ b/tests/test_var.py @@ -836,7 +836,7 @@ def test_state_with_initial_computed_var( (f"{BaseVar(_var_name='var', _var_type=str)}", "${var}"), ( f"testing f-string with {BaseVar(_var_name='myvar', _var_type=int)._var_set_state('state')}", - 'testing f-string with ${"state": "state", "interpolations": [], "imports": {"/utils/context": [{"tag": "StateContexts", "is_default": false, "alias": null, "install": true, "render": true}], "react": [{"tag": "useContext", "is_default": false, "alias": null, "install": true, "render": true}]}, "hooks": {"const state = useContext(StateContexts.state)": null}, "string_length": 13}{state.myvar}', + 'testing f-string with ${"state": "state", "interpolations": [], "imports": {"/utils/context": [{"tag": "StateContexts", "is_default": false, "alias": null, "install": true, "render": true, "transpile": false}], "react": [{"tag": "useContext", "is_default": false, "alias": null, "install": true, "render": true, "transpile": false}]}, "hooks": {"const state = useContext(StateContexts.state)": null}, "string_length": 13}{state.myvar}', ), ( f"testing local f-string {BaseVar(_var_name='x', _var_is_local=True, _var_type=str)}",