[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:
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)

View File

@ -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 = (

View File

@ -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()

View File

@ -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())

View File

@ -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]]

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 $<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)}",