[REF-2392] Expose next.config.js transpilePackages key (#3006)

This commit is contained in:
Masen Furer 2024-04-11 13:50:42 -07:00 committed by GitHub
parent d7abcd45de
commit 1a11941577
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 167 additions and 20 deletions

View File

@ -729,9 +729,12 @@ class App(Base):
for render, kwargs in DECORATED_PAGES: for render, kwargs in DECORATED_PAGES:
self.add_page(render, **kwargs) self.add_page(render, **kwargs)
def compile_(self): def compile_(self, export: bool = False):
"""Compile the app and output it to the pages folder. """Compile the app and output it to the pages folder.
Args:
export: Whether to compile the app for export.
Raises: Raises:
RuntimeError: When any page uses state, but no rx.State subclass is defined. RuntimeError: When any page uses state, but no rx.State subclass is defined.
""" """
@ -937,6 +940,17 @@ class App(Base):
# Install frontend packages. # Install frontend packages.
self.get_frontend_packages(all_imports) 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: for output_path, code in compile_results:
compiler_utils.write_page(output_path, code) compiler_utils.write_page(output_path, code)

View File

@ -64,6 +64,9 @@ class BaseComponent(Base, ABC):
# List here the non-react dependency needed by `library` # List here the non-react dependency needed by `library`
lib_dependencies: List[str] = [] 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. # The tag to use when rendering the component.
tag: Optional[str] = None tag: Optional[str] = None
@ -987,6 +990,20 @@ class Component(BaseComponent, ABC):
if getattr(self, prop) is not None 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: def _get_dependencies_imports(self) -> imports.ImportDict:
"""Get the imports from lib_dependencies for installing. """Get the imports from lib_dependencies for installing.
@ -994,7 +1011,14 @@ class Component(BaseComponent, ABC):
The dependencies imports of the component. The dependencies imports of the component.
""" """
return { 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: 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. # If the tag is dot-qualified, only import the left-most name.
tag = self.tag.partition(".")[0] if self.tag else None tag = self.tag.partition(".")[0] if self.tag else None
alias = self.alias.partition(".")[0] if self.alias 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 @staticmethod
def _get_app_wrap_components() -> dict[tuple[int, str], Component]: 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. # Do NOT import the main library/tag statically.
if self.library is not None: 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( return imports.merge_imports(
dynamic_import, dynamic_import,
@ -1529,10 +1564,7 @@ class NoSSRComponent(Component):
if self.library is None: if self.library is None:
raise ValueError("Undefined library for NoSSRComponent") raise ValueError("Undefined library for NoSSRComponent")
import_name_parts = [p for p in self.library.rpartition("@") if p != ""] import_name = format.format_library_name(self.library)
import_name = (
import_name_parts[0] if import_name_parts[0] != "@" else self.library
)
library_import = f"const {self.alias if self.alias else self.tag} = dynamic(() => import('{import_name}')" library_import = f"const {self.alias if self.alias else self.tag} = dynamic(() => import('{import_name}')"
mod_import = ( mod_import = (

View File

@ -178,7 +178,6 @@ def _run(
prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME) prerequisites.check_latest_package_version(constants.Reflex.MODULE_NAME)
if frontend: if frontend:
prerequisites.update_next_config()
# Get the app module. # Get the app module.
prerequisites.get_compiled_app() prerequisites.get_compiled_app()

View File

@ -50,10 +50,8 @@ def export(
console.rule("[bold]Compiling production app and preparing for export.") console.rule("[bold]Compiling production app and preparing for export.")
if frontend: if frontend:
# Update some parameters for export
prerequisites.update_next_config(export=True)
# Ensure module can be imported and app.compile() is called. # 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. # Set up .web directory and install frontend dependencies.
build.setup_frontend(Path.cwd()) build.setup_frontend(Path.cwd())

View File

@ -54,6 +54,10 @@ class ImportVar(Base):
# whether this import should be rendered or not # whether this import should be rendered or not
render: Optional[bool] = True 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 @property
def name(self) -> str: def name(self) -> str:
"""The name of the import. """The name of the import.
@ -72,7 +76,16 @@ class ImportVar(Base):
Returns: Returns:
The hash of the var. 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]] ImportDict = Dict[str, List[ImportVar]]

View File

@ -19,7 +19,7 @@ from datetime import datetime
from fileinput import FileInput from fileinput import FileInput
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Callable, Optional from typing import Callable, List, Optional
import httpx import httpx
import pkg_resources import pkg_resources
@ -35,6 +35,7 @@ from reflex.base import Base
from reflex.compiler import templates from reflex.compiler import templates
from reflex.config import Config, get_config from reflex.config import Config, get_config
from reflex.utils import console, path_ops, processes from reflex.utils import console, path_ops, processes
from reflex.utils.format import format_library_name
CURRENTLY_INSTALLING_NODE = False CURRENTLY_INSTALLING_NODE = False
@ -227,11 +228,12 @@ def get_app(reload: bool = False) -> ModuleType:
return app 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. """Get the app module based on the default config after first compiling it.
Args: Args:
reload: Re-import the app module from disk reload: Re-import the app module from disk
export: Compile the app for export
Returns: Returns:
The compiled app based on the default config. 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 # 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). # before compiling the app in a thread to avoid event loop error (REF-2172).
app._apply_decorated_pages() app._apply_decorated_pages()
app.compile_() app.compile_(export=export)
return app_module 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) 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. """Update Next.js config from Reflex config.
Args: Args:
export: if the method run during reflex export. 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_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: with open(next_config_file, "w") as file:
file.write(next_config) file.write(next_config)
file.write("\n") 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 = { next_config = {
"basePath": config.frontend_path or "", "basePath": config.frontend_path or "",
"compress": config.next_compression, "compress": config.next_compression,
"reactStrictMode": True, "reactStrictMode": True,
"trailingSlash": True, "trailingSlash": True,
} }
if transpile_packages:
next_config["transpilePackages"] = list(
set((format_library_name(p) for p in transpile_packages))
)
if export: if export:
next_config["output"] = "export" next_config["output"] = "export"
next_config["distDir"] = constants.Dirs.STATIC next_config["distDir"] = constants.Dirs.STATIC

View File

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import io import io
import json
import os.path import os.path
import re
import unittest.mock import unittest.mock
import uuid import uuid
from pathlib import Path 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 isinstance((third_text := page2_fragment_wrapper.children[0]), Text)
assert str(third_text.children[0].contents) == "{`third`}" # type: ignore 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

View File

@ -1,3 +1,5 @@
import json
import re
import tempfile import tempfile
from unittest.mock import Mock, mock_open from unittest.mock import Mock, mock_open
@ -61,6 +63,30 @@ def test_update_next_config(config, export, expected_output):
assert output == 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): def test_initialize_requirements_txt_no_op(mocker):
# File exists, reflex is included, do nothing # File exists, reflex is included, do nothing
mocker.patch("pathlib.Path.exists", return_value=True) mocker.patch("pathlib.Path.exists", return_value=True)

View File

@ -836,7 +836,7 @@ def test_state_with_initial_computed_var(
(f"{BaseVar(_var_name='var', _var_type=str)}", "${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')}", f"testing f-string with {BaseVar(_var_name='myvar', _var_type=int)._var_set_state('state')}",
'testing f-string with $<reflex.Var>{"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}</reflex.Var>{state.myvar}', 'testing f-string with $<reflex.Var>{"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}</reflex.Var>{state.myvar}',
), ),
( (
f"testing local f-string {BaseVar(_var_name='x', _var_is_local=True, _var_type=str)}", f"testing local f-string {BaseVar(_var_name='x', _var_is_local=True, _var_type=str)}",